feat: initial MessageWeave release

This commit is contained in:
anonsaber 2026-09-25 09:14:04 +08:00
commit dceecaf2f4
38 changed files with 9790 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
target/
.git/
.github/
.env
.env.*
*.pem
*.key
Dockerfile*
docs/
AGENTS.md

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
# stalwart-bot production bootstrap (C-REDIS-ONLY-STATE)
#
# The process requires only this environment variable. Redis ACL credentials are
# both the application connection credential and the one-shot bootstrap trust root.
# Never commit or log a real URL/password.
REDIS_URL=redis://:REPLACE_WITH_REDIS_ACL_PASSWORD@redis.example.com:6379/0
# Required 32-byte random hex key (64 hex characters). Never store in Redis or logs.
CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY
# PORT/RUN_MODE are optional platform overrides (defaults: 8080/webhook). Business
# secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap;
# they are then persisted in Redis and are never echoed by the API.

26
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: CI
on:
push:
pull_request:
jobs:
rust:
runs-on: ubuntu-latest
container: rust:1-slim-bookworm
steps:
- uses: actions/checkout@v4
# rust:1-slim-bookworm exposes rustup under /usr/local/cargo/bin but does
# not guarantee that directory is in a non-login shell's PATH.
- name: Install Rust toolchain components
run: |
export PATH="/usr/local/cargo/bin:$PATH"
rustup toolchain install stable --profile minimal
rustup default stable
rustup component add rustfmt clippy
- name: Format check
run: export PATH="/usr/local/cargo/bin:$PATH"; cargo fmt --all -- --check
- name: Clippy
run: export PATH="/usr/local/cargo/bin:$PATH"; cargo clippy --all-targets -- -D warnings
- name: Tests
run: export PATH="/usr/local/cargo/bin:$PATH"; cargo test

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/target/
/Cargo.lock
.env
.env.*
!.env.example
*.pem
*.key
*.p12

2267
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "stalwart-bot"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1"
axum = "0.8"
# Read-only HTTP JMAP only; keep WebSocket/EventSource out of the dependency graph.
jmap-client = { version = "=0.4.2", default-features = false, features = ["async", "rustls"] }
redis = { version = "0.27", default-features = false, features = ["aio", "tokio-comp", "streams", "script"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
ring = "0.17.14"
secrecy = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
subtle = "2"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
# C-DEBIAN-SLIM: Debian builder and runtime; no Alpine/musl dependencies.
FROM rust:1-slim-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY web ./web
RUN cargo build --release --locked
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install --no-install-recommends -y ca-certificates tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --home-dir /nonroot --shell /usr/sbin/nologin stalwart-bot
COPY --from=builder /build/target/release/stalwart-bot /usr/local/bin/stalwart-bot
USER stalwart-bot
ENV PORT=8080 \
RUN_MODE=webhook
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/stalwart-bot"]

104
README.md Normal file
View File

@ -0,0 +1,104 @@
# stalwart-bot
一个把 **Stalwart 邮箱**接入 **Telegram** 的个人邮件助手:新邮件来了在 Telegram 提醒你,还能按需查看/总结邮件内容。
> ⚠️ **当前仍属早期阶段,还不能真正使用。**
> 目前只有底层功能骨架;**真实邮箱的联调还没验证**。仓库已提供 Debian Dockerfile,但仍不建议生产部署。
---
## 这个项目是什么
- 你在自己的服务器上运行 [Stalwart](https://stalwart.dev) 邮箱。
- 用本工具把邮箱接到 Telegram 机器人:收到邮件时推送通知(只含发件人/主题/时间,不含正文)。
- 你可以在 Telegram 里要求查看某封邮件的原文、或让 AI 帮忙总结/翻译。
### 隐私原则
- **查看原文不会经过任何 AI**,直接读邮箱。
- **只有在你明确要求并确认后**,才把正文发给 AI 分析。
- 分析结果不会被保存。
- AI 可以完全不配置、完全关闭。
---
## 部署前,你需要准备这几样东西
1. **一个 Stalwart 邮箱账户**,并为其生成一个 **应用专用密码(App Password)**,而不是账户主密码。
2. **一个 Telegram 机器人**(通过 @BotFather 创建),拿到它的 **Bot Token**。
3. **一个 Redis 数据库**(你托管,建议开启 AOF 持久化)。机器人所有状态都放在这里。
4. **一个公网 HTTPS 地址**。平台会把这个地址指向机器人容器(HTTPS 由平台自动提供)。
5. **一个定时任务(外部 Cron / 调度器)**:每隔 5–10 分钟访问一次 `/reconcile` 地址,让机器人定期和邮箱对账。机器人自己不会设置定时器。
### 需要登记的地址
- 告诉 Telegram:把 webhook 指向 `你的公网地址/webhook/tg`
- 告诉 Stalwart:把 Push 回调指向 `你的公网地址/push/jmap`
> 如果你使用「多实例 + Cloudflare Worker 负载均衡」(可选,见 `docs/deployment.md` §10),则把上面三个 URL 指向 **Worker 的稳定域名**(`https://<your-worker>.workers.dev/webhook/tg` 等),不再指向各后端的平台地址。
---
## 在 Docker 管理器中创建容器
创建一个新容器,填好镜像、端口、内存等常规项后,只需注入 Redis 连接凭据:
| 环境变量 | 必须? | 填什么 |
|---|---|---|
| `REDIS_URL` | ✔ | Redis 连接串(含密码),例如 `redis://:密码@redis.example.com:6379/0` |
首次启动后,通过受保护的 HTTPS `/api/bootstrap` 提交 Telegram/JMAP/LLM 和业务鉴权配置。配置密钥写入 Redis,API 永不回显;管理员 PUT 成功后会校验并原子热重建客户端,后续请求即时生效,失败保留旧实例。
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
> Redis 未初始化时服务进入 configuration-only 模式,仅提供 SPA、bootstrap 和探针。
---
## 启动后怎么看它是否正常
- 浏览器或命令行访问 **`你的公网地址/healthz`**:能返回正常状态,说明机器人进程活着。
- 目前 `/ready` 只是占位,**还不能用它判断邮箱/Redis 是否正常**(这个检查以后才会补上)。
## 配置管理
打开服务根地址 **`你的公网地址/`**,输入 `REDIS_URL` 中 Redis ACL 用户的密码,创建有效期 15 分钟的管理会话。会话与表单中的密钥只保存在当前页面内存;刷新或关闭页面后需要重新授权。页面不使用浏览器存储或 Cookie 保存会话、密钥和配置。
页面分开管理两类配置:运行参数(JMAP、Telegram、LLM 请求超时与最大重试次数)可通过 API 读取和保存;业务配置(JMAP、Telegram、LLM、chat allowlist 与各鉴权 token)由 `PUT /api/business-config` 完整替换。为避免密钥回显,服务端没有业务配置读取接口,因此每次完整替换都必须重新输入必填项。保存收到 HTTP 204 后页面会提示新配置已热加载,并清空密钥输入框。
Redis 尚未连接或初始化时,管理会话或配置 API 会返回 `503`;页面会说明不可用状态,并在运行参数读取成功前禁用其保存。经 Cloudflare Worker 访问时使用 Worker 根地址;管理 API 路径也已纳入受限路由白名单。
## 隐私与运维红线(对用户透明)
- **无数据库、无本地文件**:机器人所有状态都放在你的 Redis 里;容器自身不写任何本地文件、不挂本地卷,重启后由 Redis + 邮箱对账恢复。
- **日志只走标准输出**:容器只往 stdout/stderr 打印日志,由你的平台/采集器负责收集与落盘;机器人自己**不写日志文件**。
- **日志里不会写敏感内容**:邮箱正文、密钥、AI 请求/响应、附件内容**都不会**出现在日志或 Redis 里,只记录结构化事件、计数、时间戳、脱敏摘要。
- 详见 `docs/deployment.md` §0 与 §8.2 的检查清单。
---
## 目前能做到什么 / 还差什么
**已经有了**
- 入口框架、三处安全鉴权、配置加载。
- 配置管理 SPA:使用短期 Redis admin session;可编辑完整业务配置并热加载,也可读取和保存 `/api/config` 运行参数。
- 读取 Stalwart 邮箱的基础代码(列文件夹、列邮件、读原文)。
- **可选**:一个免费 Cloudflare Worker 前置负载均衡(多实例 HA),见 `docs/deployment.md` §10 与 `cloudflare-worker/` 子目录。
**还差 / 尚未验证**
- ⚠️ **真实邮箱联调没做**:连真实 Stalwart 的测试因还没有测试环境而跳过,**没有宣称“真机通过”**。
- **Dockerfile 已提供**(Debian slim 多阶段构建);真实邮箱联调仍未验证,不能按上面的“创建容器”用于生产。
- Telegram 通知推送、AI 总结、发送邮件等功能**都还没做**。
所以:**现在还没有可以下载使用的镜像,请自行构建并等待后续版本。**
---
## 给开发/技术人员
架构、设计、部署细节见:
- [架构与设计](docs/design.md)
- [部署与运维](docs/deployment.md)
- [给 AI 编码助手的规则](AGENTS.md)
## 许可证
(待定)

3
cloudflare-worker/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.build-check/
.wrangler/

100
cloudflare-worker/README.md Normal file
View File

@ -0,0 +1,100 @@
# Stalwart-bot Cloudflare Worker(ARCH-LB-WORKER)
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
> 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。
> 安全基线:`AGENTS.md §2/§4`。
## 目录
- `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`)
- `src/index.js` — Worker 入口(路由分发、/healthz 聚合、safelist 校验)
- `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND)
- `src/proxy.js` — 透传转发 + 有界故障转移(SAF-LB-PASSTHRU)
- `src/health.js` — 健康聚合探针(MOD-HEALTH-AGG / C-NO-DB)
- `test/*.test.js` — 单元/集成测试(零依赖,`node --test`)
- `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node)
## 零依赖设计
运行时仅依赖 ES2022 标准 + 平台/Node 的 `fetch`/`Headers`/`Request`/`Response`/`URL`。
`npm run test` 直接 `node --test test/`,**无需安装**任何生产依赖;
`wrangler` 仅在 `check`/`deploy`/`dev` 中作 devDependency(CI 可选装)。
## 语义
### 透传(A,SAF-LB-PASSTHRU)
- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。
- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、
`JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 §10 仅用于多实例互斥,非鉴权)
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
- method 不符 → **405**。
### 有界故障转移(§10.4,deployment.md)
- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。
- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。
- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。
- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。
- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(§10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。
### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE)
- `GET /healthz`(由 Worker 自身承载):按 TTL 缓存(默认 30s)探测各后端 `/healthz`,
返回 `{status: ok|down, available, total, backends:[{origin, up, status}]}`;
有 ≥1 后端 up → 200;全部 down → 503。
- **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端 `GATE-READY-DEPS` 由 `/ready`(透传)承担。
### 无长连接 / 无密钥日志(§4 红线)
- 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。
- 日志只打「方法 / 路径 / origin / 失败类别」;**绝不**打印 header / body / 鉴权 secret / App Password(`SAF-LOG-PURITY`)。
## 环境变量
| 变量 | 必填 | 说明 |
|---|---|---|
| `BACKEND_ORIGINS_JSON` | 是(secret) | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。 |
| `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 |
| `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。 |
| `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 |
> secret 通过 `wrangler secret put BACKEND_ORIGINS_JSON` 注入;`wrangler.toml` 里 `vars` 保持为空,不写明文。
## 本地 / CI 验证
```bash
cd cloudflare-worker
# 单元/集成测试(零依赖):
npm run test
# 等价:node --test test/
# 语法检查 + wrangler dry-run(需 npx wrangler,会拉 devDependency):
npm run check
# 等价:node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run
```
## 部署示例
```bash
cd cloudflare-worker
# 安装 dev dep(仅 wrangler):
npm install
# 注入 secret:
npx wrangler secret put BACKEND_ORIGINS_JSON # 粘贴 ["https://a.example","https://b.example"]
# 可选调参:
npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 建议 10000
npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2
# 部署:
npx wrangler deploy
```
健康探针:
```bash
curl https://lb.stalwart-bot.example/healthz
# => {"status":"ok","available":2,"total":2,"backends":[{"origin":"https://a.example","up":true,"status":200},...]}
```

View File

@ -0,0 +1,16 @@
{
"name": "@stalwart-bot/lb-worker",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Stalwart-bot Cloudflare Worker: 统一 HTTPS 入口 + 多后端 origin 故障转移(透传模型,SAF-LB-PASSTHRU)。零运行时依赖(仅 ES2022 + Fetch API),可直接 node --test。",
"scripts": {
"test": "node --test test/",
"check": "node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run --outdir=.build-check",
"deploy": "wrangler deploy",
"dev": "wrangler dev --local"
},
"devDependencies": {
"wrangler": "^3.57.0"
}
}

View File

@ -0,0 +1,118 @@
/**
* origin 配置解析 + 校验(稳定 ID:C-HTTPS-INBOUND / SAF-LB-PASSTHRU / C-NO-DB)。
* 后端 origin 仅允许 https(C-HTTPS-INBOUND);不代理 Redis/JMAP(C-NO-DB)。
* 仅 ES2022,零依赖,可 node 直接执行。
*/
/** 允许透传的入站路由 safelist(C-LB-SINGLE-REG-URL;/healthz 由 Worker 自身聚合,不在列):未知路径 → 404。
* SPA 静态资源为公开 GET;所有管理 API 仍由后端校验 Redis bootstrap 凭据或 admin session。 */
export const SAFE_ROUTES = Object.freeze([
"/",
"/assets/config.js",
"/assets/styles.css",
"/api/config",
"/api/business-config",
"/api/admin/session",
"/api/admin/session/revoke",
"/webhook/tg",
"/push/jmap",
"/reconcile",
"/ready",
]);
/** 需要透传到后端 origin 的路由(不含 Worker 聚合端点)。 */
export const PROXIED_ROUTES = Object.freeze([...SAFE_ROUTES]);
export class BackendConfigError extends Error {
constructor(message) {
super(message);
this.name = "BackendConfigError";
}
}
const HTTP_STATUS = 80;
const HTTPS_STATUS = 443;
/**
* 校验并归一化单个 backend origin。
* 规则(C-HTTPS-INBOUND,对齐 §10 的 fail-closed):
* - 必须是 https;
* - 必须无内嵌凭据(禁 user:pass@host);
* - 必须无 query / fragment(origin 不应带这些);
* - 路径必须为空(origin 根),禁止 /path 形式(防止把路由写进 origin)。
* @param {string} raw
* @returns {{ url: string, host: string, port: number }}
*/
export function parseBackendOrigin(raw) {
if (typeof raw !== "string" || raw.trim() === "") {
throw new BackendConfigError("backend origin must be a non-empty https URL");
}
let parsed;
try {
parsed = new URL(raw.trim());
} catch {
throw new BackendConfigError(`backend origin is not a valid URL: ${raw}`);
}
if (parsed.protocol !== "https:") {
throw new BackendConfigError(
`backend origin must use https (C-HTTPS-INBOUND): ${raw}`,
);
}
if (parsed.username !== "" || parsed.password !== "") {
throw new BackendConfigError(
`backend origin must not embed credentials (C-HTTPS-INBOUND): ${parsed.host}`,
);
}
if (parsed.search !== "" || parsed.hash !== "") {
throw new BackendConfigError(
`backend origin must not contain query/fragment: ${parsed.host}`,
);
}
if (parsed.pathname !== "" && parsed.pathname !== "/") {
throw new BackendConfigError(
`backend origin must be a bare origin (no path): ${raw}`,
);
}
// 记录端口以便健康探测/请求拼 URL;缺省 443。
const port = parsed.port !== "" ? Number(parsed.port) : HTTPS_STATUS;
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new BackendConfigError(`backend origin has an invalid port: ${raw}`);
}
return { url: parsed.origin, host: parsed.hostname, port };
}
/**
* 解析 `BACKEND_ORIGINS_JSON` 环境变量:JSON 字符串数组。
* 全部校验通过后返回去重(保持顺序)的 origins;无有效条目则报错。
* @param {unknown} raw 可能为 JSON 字符串或已解析数组
* @returns {Array<{url:string, host:string, port:number, source:string}>}
*/
export function parseBackendOrigins(raw) {
let list;
if (Array.isArray(raw)) {
list = raw;
} else if (typeof raw === "string") {
try {
list = JSON.parse(raw);
} catch {
throw new BackendConfigError("BACKEND_ORIGINS_JSON is not valid JSON");
}
} else {
throw new BackendConfigError("BACKEND_ORIGINS_JSON must be a JSON array of https origins");
}
if (!Array.isArray(list) || list.length === 0) {
throw new BackendConfigError("BACKEND_ORIGINS_JSON must be a non-empty array");
}
const seen = new Set();
const out = [];
for (const entry of list) {
const origin = parseBackendOrigin(entry);
if (!seen.has(origin.url)) {
seen.add(origin.url);
out.push(origin);
}
}
return out;
}
export { HTTP_STATUS, HTTPS_STATUS };

View File

@ -0,0 +1,80 @@
/**
* 健康聚合探针(稳定 ID:MOD-HEALTH-AGG │ C-NO-DB │ C-REDIS-ONLY-STATE)。
*
* 语义:
* - Worker 暴露 LB 级 `GET /healthz`,聚合探测各后端 origin 的 `/healthz`(后端公开探针,无敏感信息)。
* - 缓存 TTL(默认 30秒)避免健康探测放大;TTL 内复用上次结果,过期后才重探。
* - 探测失败/超时 → 该 origin 记为 down;`available` 统计存活数。
* - 仅 HTTP 层健康(<500 记为 up):**不**检查/代理 Redis、JMAP(C-NO-DB / C-REDIS-ONLY-STATE),
* 就绪依赖检查仍由后端 `/ready`(GATE-READY-DEPS)承担。
*
* 可测性:fetch / 计时器 / 时钟均依赖注入。
*/
export const DEFAULT_HEALTH_TTL_MS = 30_000;
/**
* 聚合探测所有后端 origin 的 `/healthz`。
* @param {string[]} origins 已校验的 origin 列表
* @param {object} ctx { fetch, timeoutMs, ttlMs?, now?, cache?, setTimeout?, clearTimeout? }
* @returns {Promise<{available:number,total:number,backends:Array<{origin:string,up:boolean,status:number|null}>}>}
*/
export async function aggregateHealth(origins, ctx) {
const {
fetch,
timeoutMs,
ttlMs = DEFAULT_HEALTH_TTL_MS,
now = () => Date.now(),
cache,
setTimeout: st = globalThis.setTimeout,
clearTimeout: ct = globalThis.clearTimeout,
} = ctx;
const backends = [];
let available = 0;
for (const origin of origins) {
const key = `${origin}/healthz`;
const cached = cache ? cache.get(key) : undefined;
let up = false;
let status = null;
if (cached && ttlMs > 0 && now() - cached.at < ttlMs) {
up = cached.up;
status = cached.status;
} else {
try {
const controller = new AbortController();
const timer = st(() => controller.abort(), timeoutMs);
const resp = await fetch(key, { method: "GET", signal: controller.signal });
ct(timer);
status = resp.status;
up = resp.status < 500;
} catch {
up = false;
status = null;
}
if (cache) cache.set(key, { at: now(), up, status });
}
if (up) available += 1;
backends.push({ origin, up, status });
}
return { available, total: origins.length, backends };
}
/**
* 组装 LB 级 `/healthz` 响应:有 ≥1 后端存活 → 200;全 down → 503;无配置 → 200 + 明确标记。
* @param {{available:number,total:number,backends:unknown[]}} summary
* @returns {Response}
*/
export function healthResponse(summary) {
const healthy = summary.total > 0 && summary.available > 0;
const status =
summary.total === 0 ? "no-backends" : healthy ? "ok" : "down";
return new Response(JSON.stringify({ status, ...summary }), {
status: summary.total === 0 ? 200 : healthy ? 200 : 503,
headers: { "content-type": "application/json" },
});
}

View File

@ -0,0 +1,150 @@
/**
* Cloudflare Worker 入口 — Stalwart-bot 多实例 LB/HA(ARCH-LB-WORKER / deployment.md §10)。
*
* 稳定 ID(AGENTS.md §5/§7):
* - SAF-LB-PASSTHRU:透传 headers/body(含鉴权头),不做鉴权改写。
* - C-HTTPS-INBOUND:仅 https 后端 origin。
* - ARCH-LB-WORKER / C-LB-SINGLE-REG-URL:safelist 路由,未知路径 404、method 不符 405。
* - MOD-HEALTH-AGG:/healthz 由 Worker 聚合,不落后端。
* - C-NO-DB / C-REDIS-ONLY-STATE:不代理 Redis/JMAP;不检查数据库侧可用性。
* - C-NO-LONG-CONN:纯请求-响应,body 一次性读入回灌;无 WS/SSE/长轮询。
* - C-NO-SECRET-IN-IMAGE:日志仅「方法/路径/origin/失败类别」,不含 secret;origin 清单走 wrangler secret。
*
* 路由模型:
* - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传
* - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke]
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
* - POST /webhook/tg|/push/jmap|/reconcile、GET /ready → 透传 + 有界故障转移
* - 其它 → 404 / 405
*
* 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。
*/
import { parseBackendOrigins, PROXIED_ROUTES } from "./backends.js";
import { proxyWithFailover } from "./proxy.js";
import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js";
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议)
/**
* 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。
* 仅存「origin → {at,up,status}」,不含任何 header/body/secret(SAF-LOG-PURITY 精神)。
* TTL 由 LB_HEALTH_TTL_MS 控制;isolate 冷启时为空,首个 /healthz 全量探测。
*/
const healthCache = new Map();
/** 各透传路由允许的 method(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL;其余 405,不透传至后端)。 */
const ROUTE_METHODS = Object.freeze({
"/": ["GET"],
"/assets/config.js": ["GET"],
"/assets/styles.css": ["GET"],
"/api/config": ["GET", "PUT"],
"/api/business-config": ["PUT"],
"/api/admin/session": ["POST"],
"/api/admin/session/revoke": ["POST"],
"/webhook/tg": ["POST"],
"/push/jmap": ["POST"],
"/reconcile": ["POST"],
"/ready": ["GET"],
});
/**
* Worker 主处理器(纯函数,便于单测)。
* @param {Request} request
* @param {object} env { BACKEND_ORIGINS_JSON, LB_REQUEST_TIMEOUT_MS?, LB_MAX_ATTEMPTS? }
* @returns {Promise<Response>}
*/
export async function handleFetch(request, env) {
const path = new URL(request.url).pathname;
// LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。
if (path === "/healthz" && request.method === "GET") {
return handleHealth(env);
}
// safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。
if (!PROXIED_ROUTES.includes(path)) {
return text(`route not proxied: ${path}`, 404);
}
const allowed = ROUTE_METHODS[path];
if (!allowed || !allowed.includes(request.method)) {
return text(`method not allowed for ${path}`, 405, { allow: allowed?.join(", ") });
}
let origins;
try {
origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url);
} catch {
return text("misconfigured backends", 503); // fail-closed,不泄漏 secret
}
if (origins.length === 0) {
return text("no backends configured", 503);
}
const timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS);
// 透传转发 + 有界故障转移(仅超时/5xx)。
return proxyWithFailover(origins, request, {
maxAttempts,
timeoutMs,
fetch: lazyFetch(),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
rng: Math.random,
log: (msg) => {
try {
console.error(`[lb] ${request.method} ${path}: ${msg}`);
} catch {
/* 日志不可用不致命 */
}
},
});
}
/** 透传路由的统一错误/兜底日志不打印任何 header/body。 */
async function handleHealth(env) {
let origins;
try {
origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url);
} catch {
return healthResponse({ available: 0, total: 0, backends: [] });
}
if (origins.length === 0) {
return healthResponse({ available: 0, total: 0, backends: [] });
}
const summary = await aggregateHealth(origins, {
fetch: lazyFetch(),
timeoutMs: intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
ttlMs: intFromEnv(env.LB_HEALTH_TTL_MS, DEFAULT_HEALTH_TTL_MS),
cache: healthCache,
});
return healthResponse(summary);
}
/** 惰性绑定 globalThis.fetch,避免解构时丢失 this。 */
function lazyFetch() {
return (...args) => globalThis.fetch(...args);
}
function text(body, status, extraHeaders = {}) {
return new Response(body, {
status,
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
});
}
function intFromEnv(raw, fallback) {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.floor(n);
}
/** Cloudflare Workers module 入口。 */
export default {
async fetch(request, env, _ctx) {
return handleFetch(request, env);
},
};

View File

@ -0,0 +1,146 @@
/**
* 请求转发与有界故障转移(稳定 ID:SAF-LB-PASSTHRU / C-NO-LONG-CONN / C-NO-SECRET-IN-IMAGE / deployment.md §10.4)。
*
* 语义:
* - 透传(A):原样转发 method / headers / body(含鉴权头),不做鉴权改写(SAF-LB-PASSTHRU)。
* - 仅对「超时 / 5xx」做有界重试:换下一个 origin 再试(deployment.md §10.4 建议 1 次);
* 非 5xx / 非超时直接返回。
* - 全失败 → 返回 5xx(交由 Telegram 自动重投,不丢消息)。
* - 无 WebSocket/SSE/长轮询(C-NO-LONG-CONN):纯请求-响应,body 一次性读入后回灌。
*
* 可测性:所有 I/O(fetch/计时器/随机)通过依赖注入传入,便于 mock。
* 「无密钥日志」红线(SAF-LOG-PURITY):只打错误类别与 origin(已校验、非敏感),
* **不打印** header/body/secret;不产生任何文件写入,日志仅 stdout。
*/
/**
* @typedef {Request} Req
* @typedef {Response} Res
*/
/**
* 拼接后端 URL:origin + path + query(保留 query,丢弃 fragment)。
* @param {string} origin 已校验的 https origin
* @param {Req} request
* @returns {string}
*/
export function joinUrl(origin, request) {
const u = new URL(request.url);
return `${origin}${u.pathname}${u.search}`;
}
/**
* 一次性读取请求 body 为可复用的 Uint8Array(供每个故障转移尝试回灌,
* 避免 Request.body 流在第二次尝试时被“已消费”而抛 TypeError)。
* @param {Req} request
* @returns {Promise<{headers: Headers, body: Uint8Array | undefined}>}
*/
export async function snapshotBody(request) {
const headers = new Headers(request.headers);
const hasBody = request.method !== "GET" && request.method !== "HEAD";
const body = hasBody ? new Uint8Array(await request.arrayBuffer()) : undefined;
return { headers, body };
}
/** 是否「可故障转移」:5xx 状态码、超时/网络类错误(err)。 */
export function isRetryableError(err, status) {
if (err) {
return isRetryableNetworkError(err);
}
return status >= 500 && status < 600;
}
/**
* 是否「可故障转移」的网络错误:
* 超时(AbortError)或 fetch/连接层网络错误(Node: `fetch failed`/`ECONNREFUSED` 等)。
* 其它错误(如 body 读取失败、配置错误)**不**触发故障转移,避免无谓重试。
*/
function isRetryableNetworkError(err) {
if (!err) return false;
const name = String(err.name || "");
const msg = String(err.message || "");
if (name === "AbortError") return true;
if (/fetch failed|network error|socket hang up|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return true;
// 递归 cause 链(Node 的 fetch 网络错误把 errno 藏在 cause)。
if (err.cause && err.cause !== err) return isRetryableNetworkError(err.cause);
return false;
}
/**
* 单次 origin 尝试:透传转发。超时/网络错误会 throw(由调用方处理)。
* 使用预读的 body 快照(Uint8Array),避免 Request.body 流在第二次尝试时“已消费”。
* @param {string} origin
* @param {Req} request
* @param {object} io { fetch, setTimeout, clearTimeout, timeoutMs }
* @param {{headers: Headers, body: Uint8Array | undefined}} snapshot 由 snapshotBody 产出
* @returns {Promise<Res>}
*/
export async function proxyRequest(origin, request, io, snapshot) {
const { fetch, setTimeout: st, clearTimeout: ct, timeoutMs } = io;
const url = joinUrl(origin, request);
const controller = new AbortController();
const timer = st(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(url, {
method: request.method,
headers: snapshot.headers,
body: snapshot.body ? new Uint8Array(snapshot.body) : undefined,
redirect: "follow",
signal: controller.signal,
});
return resp;
} finally {
ct(timer);
}
}
/**
* 有界故障转移:按序尝试 origins(起点随机化实现双活分摊,确定性可注入);
* 仅超时/5xx 才换下一个实例;全失败 → 5xx 聚合响应。
* @param {string[]} origins 有序 origin 列表(≥1)
* @param {Req} request 入站请求
* @param {object} opts { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng?, log? }
* @returns {Promise<Res>}
*/
export async function proxyWithFailover(origins, request, opts) {
const { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng, log } = opts;
const start = origins.length > 1 && rng ? Math.floor(rng() * origins.length) : 0;
const queue = origins.slice(start).concat(origins.slice(0, start));
const attempts = Math.max(1, Math.min(maxAttempts, queue.length));
let lastFailure = "no backends configured";
// 一次性读 body 快照:让每次故障转移尝试可回灌同一请求体(body 流只能读一次)。
const snapshot = await snapshotBody(request);
for (let i = 0; i < attempts; i += 1) {
const origin = queue[i];
const io = { fetch, setTimeout, clearTimeout, timeoutMs };
try {
const resp = await proxyRequest(origin, request, io, snapshot);
// 非 5xx / 非网络错误:直接返回(4xx、2xx、3xx 均原样透传,不故障转移)。
if (!isRetryableError(null, resp.status)) {
return mark(resp, origin);
}
// 5xx:释放 body,换下一个实例。
resp.body?.cancel?.().catch(() => {});
lastFailure = `backend ${origin} returned HTTP ${resp.status}`;
if (log) log(`retrying ${request.method} ${new URL(request.url).pathname}: ${lastFailure}`);
} catch (err) {
lastFailure = `backend ${origin} unavailable (${err && err.name ? err.name : "error"})`;
if (log) log(lastFailure);
// 仅「可故障转移」的网络类失败(超时/连接错误)才换实例;其它错误 fail-fast,避免无限重试。
if (!isRetryableError(err, 0)) break;
}
}
// 全部 origin 失败 → 5xx(交由 Telegram/Stalwart 自动重投兜底,不丢消息)。
return new Response(`all backends failed: ${lastFailure}`, {
status: 503,
statusText: "All Backends Unavailable",
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
/** 在响应上加轻量可观测头(不改 body/状态语义;origin 非敏感,含在响应头中便于排障)。 */
function mark(resp, origin) {
resp.headers.set("x-lb-backend", origin);
return resp;
}

View File

@ -0,0 +1,61 @@
/**
* 单元测试:origin configuration 校验(C-HTTPS-INBOUND / C-LB-SINGLE-REG-URL)。
* 运行:cd cloudflare-worker && node --test
*/
import test from "node:test";
import assert from "node:assert/strict";
import { parseBackendOrigin, parseBackendOrigins, SAFE_ROUTES, PROXIED_ROUTES } from "../src/backends.js";
test("http origin is rejected (https-only, fail-closed)", () => {
assert.throws(() => parseBackendOrigin("http://bot.example"), /https/);
});
test("non-https / invalid origins are rejected", () => {
for (const bad of ["", " ", "not a url", "://x", "ws://bot.example", "wss://bot.example", "http://x"]) {
assert.throws(() => parseBackendOrigin(bad), Error, `must reject: ${deep(bad)}`);
}
});
test("embedded credentials / query / fragment / path are rejected", () => {
assert.throws(() => parseBackendOrigin("https://user:pass@bot.example"), /credentials/);
assert.throws(() => parseBackendOrigin("https://bot.example?x=1"), /query|fragment/);
assert.throws(() => parseBackendOrigin("https://bot.example#frag"), /query|fragment/);
assert.throws(() => parseBackendOrigin("https://bot.example/api"), /bare origin/);
});
test("valid https origins normalize to origin form", () => {
const a = parseBackendOrigin("https://bot.example/");
assert.equal(a.url, "https://bot.example");
assert.equal(a.host, "bot.example");
assert.equal(a.port, 443);
const b = parseBackendOrigin("https://bot.example:8443");
assert.equal(b.url, "https://bot.example:8443");
assert.equal(b.port, 8443);
});
test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => {
const raw = JSON.stringify(["https://a.example", "https://b.example", "https://a.example"]);
const list = parseBackendOrigins(raw);
assert.deepEqual(list.map((o) => o.url), ["https://a.example", "https://b.example"]);
assert.throws(() => parseBackendOrigins(JSON.stringify(["https://a.example", "http://c.example"])), /https/);
assert.throws(() => parseBackendOrigins("nope"), /valid JSON/);
assert.throws(() => parseBackendOrigins("[]"), /non-empty/);
});
test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => {
for (const r of [
"/", "/assets/config.js", "/assets/styles.css", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
]) {
assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`);
}
for (const r of ["/healthz", "/admin", "/debug", "/jmap/session", "/ready/extra"]) {
assert.ok(!PROXIED_ROUTES.includes(r), `must be blocked: ${r}`);
assert.ok(!SAFE_ROUTES.includes(r), `must not be a safe route: ${r}`);
}
});
/** 让断言失败信息可读,且不泄漏敏感值。 */
function deep(v) {
return typeof v === "string" && v.length > 48 ? `${v.slice(0, 12)}…(${v.length})` : String(v);
}

View File

@ -0,0 +1,97 @@
/**
* 单元测试:健康聚合(MOD-HEALTH-AGG / C-NO-DB / C-REDIS-ONLY-STATE)。
* 运行:cd cloudflare-worker && node --test
*/
import test from "node:test";
import assert from "node:assert/strict";
import { aggregateHealth, healthResponse } from "../src/health.js";
function mockCtx(plan, opts = {}) {
const calls = [];
const cache = opts.cache ?? new Map();
let time = 1_000_000;
const fetch = async (url) => {
calls.push(url);
const step = plan[url] ?? { status: 500 };
if (step.error) throw step.error;
return new Response(step.body ?? "ok", { status: step.status });
};
return {
fetch,
timeoutMs: 1000,
ttlMs: opts.ttlMs ?? 30_000,
now: () => time,
advance: (ms) => {
time += ms;
},
setTimeout: () => 0,
clearTimeout: () => {},
cache,
calls,
};
}
test("aggregateHealth: all up => 200 ok summary", async () => {
const ctx = mockCtx({
"https://a.example/healthz": { status: 200 },
"https://b.example/healthz": { status: 200 },
});
const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx);
assert.equal(res.available, 2);
assert.equal(res.total, 2);
const resp = healthResponse(res);
assert.equal(resp.status, 200);
const body = await resp.json();
assert.equal(body.status, "ok");
assert.equal(body.available, 2);
assert.equal(body.total, 2);
});
test("aggregateHealth: partial down => still reports status (partial ok)", async () => {
const ctx = mockCtx({
"https://a.example/healthz": { status: 200 },
"https://b.example/healthz": { error: new Error("conn refused") },
});
const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx);
assert.equal(res.available, 1);
assert.equal(res.total, 2);
const resp = healthResponse(res);
assert.equal(resp.status, 200, "at least one up => 200 ok");
const body = await resp.json();
assert.equal(body.status, "ok");
assert.equal(body.available, 1);
});
test("aggregateHealth: all down => 503", async () => {
const ctx = mockCtx({
"https://a.example/healthz": { status: 503 },
"https://b.example/healthz": { error: new Error("conn refused") },
});
const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx);
assert.equal(res.available, 0);
const resp = healthResponse(res);
assert.equal(resp.status, 503);
const body = await resp.json();
assert.equal(body.status, "down");
});
test("aggregateHealth: respects cache TTL to avoid probe storm", async () => {
const ctx = mockCtx(
{
"https://a.example/healthz": { status: 200 },
},
{ ttlMs: 10_000 },
);
await aggregateHealth(["https://a.example"], ctx);
assert.equal(ctx.calls.length, 1);
// 未过 TTL:命中缓存,不再发起 fetch。
ctx.advance(5_000);
await aggregateHealth(["https://a.example"], ctx);
assert.equal(ctx.calls.length, 1);
// 超过 TTL:发起新 fetch。
ctx.advance(6_000);
await aggregateHealth(["https://a.example"], ctx);
assert.equal(ctx.calls.length, 2);
});

View File

@ -0,0 +1,109 @@
/**
* 集成测试:Cloudflare Worker 主入口 handle fetch(ARCH-LB-WORKER / SAF-LB-PASSTHRU)。
* 验证:路由分发、/healthz 聚合、未白名单 404、method 拦截、配置异常 fail-closed、鉴权头透传。
* 运行:cd cloudflare-worker && node --test
*/
import test from "node:test";
import assert from "node:assert/strict";
import { handleFetch } from "../src/index.js";
function makeEnv(overrides = {}) {
return {
BACKEND_ORIGINS_JSON: JSON.stringify(["https://a.example", "https://b.example"]),
LB_REQUEST_TIMEOUT_MS: "5000",
LB_MAX_ATTEMPTS: "2",
...overrides,
};
}
/** 安装/卸载 globalThis.fetch mock。 */
function stubFetch(impl) {
const orig = globalThis.fetch;
globalThis.fetch = impl;
return () => {
globalThis.fetch = orig;
};
}
test("Worker GET /healthz aggregates backend probes without touching proxied routes", async () => {
const restore = stubFetch(async (url) => new Response("ok", { status: 200 }));
try {
const res = await handleFetch(new Request("https://lb.example/healthz", { method: "GET" }), makeEnv());
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.status, "ok");
assert.equal(body.available, 2);
assert.equal(body.total, 2);
assert.ok(Array.isArray(body.backends));
} finally {
restore();
}
});
test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => {
const res = await handleFetch(
new Request("https://lb.example/unknown", { method: "POST", body: "{}" }),
makeEnv(),
);
assert.equal(res.status, 404);
assert.match(await res.text(), /route not proxied/);
});
test("Worker: wrong method on safelist route => 405", async () => {
const res = await handleFetch(
new Request("https://lb.example/webhook/tg", { method: "GET" }),
makeEnv(),
);
assert.equal(res.status, 405);
});
test("Worker: business config PUT is proxied with the session bearer and empty 204 response", async () => {
const restore = stubFetch(async (url, init) => {
assert.match(url, /^https:\/\/(a|b)\.example\/api\/business-config$/);
assert.equal(init.method, "PUT");
assert.equal(init.headers.get("authorization"), "Bearer opaque-session");
assert.equal(await new Response(init.body).text(), "{\"llm_enabled\":false}");
return new Response(null, { status: 204 });
});
try {
const res = await handleFetch(
new Request("https://lb.example/api/business-config", {
method: "PUT",
headers: { authorization: "Bearer opaque-session", "content-type": "application/json" },
body: "{\"llm_enabled\":false}",
}),
makeEnv(),
);
assert.equal(res.status, 204);
} finally {
restore();
}
});
test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => {
const res = await handleFetch(
new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }),
makeEnv({ BACKEND_ORIGINS_JSON: "not-json" }),
);
assert.equal(res.status, 503);
assert.equal(await res.text(), "misconfigured backends");
});
test("Worker: valid env, GET /ready proxied to backend with auth header intact", async () => {
const restore = stubFetch(async (url, init) => {
assert.match(url, /^https:\/\/(a|b)\.example\/ready$/);
return new Response("ready", { status: 200, headers: { "content-type": "text/plain" } });
});
try {
const res = await handleFetch(
new Request("https://lb.example/ready", {
method: "GET",
headers: { authorization: "Bearer lb-test" },
}),
makeEnv(),
);
assert.equal(res.status, 200);
} finally {
restore();
}
});

View File

@ -0,0 +1,172 @@
/**
* 单元测试:透传转发 + 有界故障转移 + 全失败 5xx(SAF-LB-PASSTHRU / SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE)。
* 运行:cd cloudflare-worker && node --test
*/
import test from "node:test";
import assert from "node:assert/strict";
import { proxyWithFailover, joinUrl, isRetryableError } from "../src/proxy.js";
/**
* 可控 fetch mock:steps[i] 每次调用消费一个;
* - {status, body?} → 返回该 Response;
* - {error: Error} → 抛出。
*/
function mockFetch(steps) {
const calls = [];
const fetch = async (url, init) => {
const i = calls.length;
calls.push({ url, init });
const step = steps[Math.min(i, steps.length - 1)];
if (step.error) throw step.error;
return new Response(step.body ?? "ok", { status: step.status, headers: { "content-type": "text/plain" } });
};
return { fetch, calls };
}
/** 测试用计时器:不真触发超时。 */
const noOpTimers = { setTimeout: () => 0, clearTimeout: () => {} };
function req(path = "/reconcile") {
return new Request(`https://lb.example${path}`, {
method: "POST",
headers: { authorization: "Bearer secret-token", "content-type": "application/json" },
body: JSON.stringify({ id: 42 }),
});
}
function abortError() {
const err = new Error("The operation was aborted");
err.name = "AbortError";
return err;
}
test("isRetryableError: only 5xx (status) and timeout/network (err) are retryable", () => {
assert.equal(isRetryableError(new Error("boom"), 0), false, "generic error not retryable");
assert.equal(isRetryableError(abortError(), 0), true, "AbortError retryable");
assert.equal(isRetryableError(new Error("fetch failed"), 0), true, "network-class message retryable");
assert.equal(isRetryableError(new Error("bad inner state"), 0), false, "non-network error not retryable");
assert.equal(isRetryableError(null, 503), true, "5xx retryable");
assert.equal(isRetryableError(null, 404), false, "4xx not retryable");
assert.equal(isRetryableError(null, 200), false, "2xx not retryable");
});
test("transparent proxy forwards headers + body + method (SAF-LB-PASSTHRU)", async () => {
const { fetch, calls } = mockFetch([{ status: 202, body: "accepted" }]);
const res = await proxyWithFailover(["https://a.example"], req("/webhook/tg"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0.5,
});
assert.equal(res.status, 202);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "https://a.example/webhook/tg");
assert.equal(calls[0].init.method, "POST");
assert.equal(calls[0].init.headers.get("authorization"), "Bearer secret-token");
assert.equal(calls[0].init.headers.get("content-type"), "application/json");
const body = await new Response(new Uint8Array(calls[0].init.body)).text();
assert.equal(body, JSON.stringify({ id: 42 }));
assert.equal(res.headers.get("x-lb-backend"), "https://a.example");
});
test("GET/HEAD do not forward a body", async () => {
const { fetch, calls } = mockFetch([{ status: 200 }]);
const get = new Request("https://lb.example/ready", { method: "GET", headers: { authorization: "Bearer x" } });
await proxyWithFailover(["https://a.example"], get, {
...noOpTimers,
fetch,
maxAttempts: 1,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(calls[0].init.body, undefined, "GET must not carry a body");
});
test("4xx is returned as-is WITHOUT retry and without failing over", async () => {
const { fetch, calls } = mockFetch([{ status: 401, body: "unauthorized" }, { status: 200 }]);
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 401);
assert.equal(calls.length, 1, "must NOT retry/share next backend on 4xx");
assert.equal(calls[0].url, "https://a.example/push/jmap");
});
test("5xx triggers failover to next origin, then returns success", async () => {
const { fetch, calls } = mockFetch([{ status: 503, body: "busy" }, { status: 200, body: "ok" }]);
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0, // 确定性起点 a
});
assert.equal(res.status, 200);
assert.equal(calls.length, 2);
assert.equal(calls[0].url, "https://a.example/push/jmap");
assert.equal(calls[1].url, "https://b.example/push/jmap");
assert.equal(res.headers.get("x-lb-backend"), "https://b.example");
});
test("timeout(AbortError) triggers failover and recovery", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 200, body: "recovered" }]);
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/reconcile"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 200);
assert.equal(calls.length, 2);
});
test("all backends 5xx/timeout => aggregated 503 (Telegram redelivery fallback)", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 503, body: "x" }]);
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 503);
assert.equal(calls.length, 2);
assert.match(await res.text(), /all backends failed/);
});
test("bounded attempts: maxAttempts caps the number of origin tries", async () => {
const { fetch, calls } = mockFetch([{ error: abortError() }]); // 每次都超时(可故障转移)
const res = await proxyWithFailover(["https://a", "https://b", "https://c"], req("/reconcile"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 503);
assert.equal(calls.length, 2, "must stop after maxAttempts=2 (bounded, no infinite retry)");
});
test("non-retryable (non-network) error fails fast without burning other origins", async () => {
const { fetch, calls } = mockFetch([{ error: new Error("invalid inner state") }, { status: 200 }]);
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
...noOpTimers,
fetch,
maxAttempts: 2,
timeoutMs: 5000,
rng: () => 0,
});
assert.equal(res.status, 503);
assert.equal(calls.length, 1, "non-network error must not failover");
});
test("joinUrl preserves path + query, discards fragment", () => {
const r = new Request("https://lb.example/reconcile?cursor=abc#frag", { method: "POST" });
assert.equal(joinUrl("https://a.example", r), "https://a.example/reconcile?cursor=abc");
});

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"noUnusedLocals": true
},
"include": ["src/**/*.js"]
}

View File

@ -0,0 +1,31 @@
/**
* Wrangler 示例配置 — Stalwart-bot 多实例 LB/HA(ARCH-LB-WORKER / deployment.md §10)。
*
* 安全约束(AGENTS.md §2/§4,稳定 ID):
* - 后端 origin 仅允许 https(C-HTTPS-INBOUND / C-HTTPS-URL);env 校验时拒绝
* http/非 https/内嵌凭据。
* - 不代理 Redis/JMAP(C-NO-DB / C-REDIS-ONLY-STATE):Worker 只做请求转发,后端连自己的 Redis。
* - 不使用 WebSocket/SSE/长轮询(C-NO-LONG-CONN):纯请求-响应转发。
* - 后端 secret 走 `wrangler secret`(见 README),不进代码/日志(SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE;
* C-LB-SHARED-SECRETS:所有后端实例共享同一组 secret)。
* - 日志无密钥(SAF-LOG-PURITY):只打「方法/路径/origin/失败类别」,origin 清单走 secret。
* - 仅允许转发 safelist 路由(C-LB-SINGLE-REG-URL);未知路径 404。
*
* 部署:
* cd cloudflare-worker
* npx wrangler secret put BACKEND_ORIGINS_JSON # 见下方示例格式
* npx wrangler deploy
*/
name = "stalwart-bot-lb"
main = "src/index.js"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat_v2"]
# 统一 HTTPS 入站(C-HTTPS-URL;平台代 TLS,Worker 内为短请求模型)。
# 后端 origin 为 secret(不经明文 env 流转):
# BACKEND_ORIGINS_JSON 形如 ["https://a.platform1.example","https://b.platform2.example"]
# LB_HEALTH_TTL_MS 可选,健康缓存 TTL(默认 30000)
# LB_REQUEST_TIMEOUT_MS 可选,单 origin 超时(默认 10000,建议 > 最坏 cold start)
# LB_MAX_ATTEMPTS 可选,每请求最多 origin 尝试次数(默认 2:首次 + 1 次故障转移,
# 对应 deployment.md §10.4「有界重试其它实例(建议 1 次)」)
vars = {}

428
docs/deployment.md Normal file
View File

@ -0,0 +1,428 @@
# Stalwart-bot 部署方案(通用 HTTPS-only Docker)
> 部署目标:**通用 Docker 容器平台**(任何能跑 Docker 的 VPS / 云主机 / k8s / 自托管容器平台),
> **不绑定** Cloud Run / Lambda / CF Workers / Deno Deploy 等任何具体平台(`NG-SERVERLESS-BIND`)。
>
> 保持 Docker health / PORT 等通用 HTTP 约定(`C-PORT`)。
> 部署形态 = **短请求模型**:Telegram Webhook + Stalwart JMAP Push HTTPS 回调 + 外部 HTTPS Cron 对账。
> 硬约束见 [§0](#0-硬约束);非目标见 [§1](#1-非目标non-goals);
> 架构 / 产品行为以 [design.md](design.md) 为准,本文只写部署/运维。
---
## 0. 硬约束
> 下述约束均为**不可违反**项(对应 AGENTS.md 硬边界)。跨文档引用一律用稳定 ID,索引见 AGENTS.md「跨文档引用索引表」。
| ID | 约束 |
|---|---|
| **C-DOCKER** | 必须是 Docker 容器;禁止非 Docker 直装 |
| **C-DEBIAN-SLIM** | Debian 系 slim,禁止 Alpine/musl |
| **C-NO-SECRET-IN-IMAGE** | secrets 不进镜像(构建 ARG/ENV 不得含 token/密码) |
| **C-RUSTLS** | rustls + rustls-native-roots,不依赖系统 OpenSSL |
| **C-HTTPS-INBOUND** | 只提供 HTTPS 入站:容器内仅监听明文 HTTP,TLS 由平台入口/反向代理统一终止;无代理场景才允许容器内自服务 TLS |
| **C-HTTPS-URL** | **公网 HTTPS 入口由平台提供**:平台/反代给 bot 一个公网 HTTPS URL(如 `https://bot.example.com`),Telegram Webhook 与 Stalwart Push 回调都指向该 URL 的路径;bot 自身不申请证书、不监听 443 |
| **C-NO-TCP-EXPOSE** | 不依赖 TCP 端口暴露:全容器**单监听端口 `PORT`(默认 8080)**;对外只走 HTTP 路由(webhook / push / reconcile / health);不暴露任何附加 TCP 端口(如 admin 端口) |
| **C-NO-LONG-CONN** | 不使用任何长连接:无 JMAP EventSource/SSE、无 WebSocket、无 Telegram 长轮询。实时性仅由 **Push HTTPS 回调 + 外部 HTTPS Cron 对账** 保证 |
| **C-REDIS-ONLY-STATE** | 无状态形态只依赖**外部 Redis**:会话、去重、Redis Streams、熔断计数、**sinceState 游标** 全部走 Redis;**不用 SQLite、不用本地卷**(`NG-SQLITE-PERSIST` / `NG-LOCAL-VOLUME`) |
| **C-REDIS-MANAGED-AOF** | Redis 由**用户托管**(自建或托管服务)并**开启 AOF 持久化**:Bot 不自建、不管理 Redis 进程;AOF 保证 Streams 队列 / 去重表 / sinceState 重启不丢 |
| **C-NO-DB** | **生产不使用任何数据库**:无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一的状态存储(`C-REDIS-ONLY-STATE`)。应用不得自带、不自建、不连接第二个数据库实例 |
| **C-NO-LOCAL-WRITE** | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷(`NG-LOCAL-VOLUME`/`NG-SQLITE-PERSIST`) |
| **C-LOG-STDOUT-ONLY** | **日志只写 stdout/stderr**(容器平台/运行时负责采集落盘);禁用 `rolling-file`、`FileAppender` 等文件日志后端;日志中**不得**出现密钥、邮件正文、AI 请求/响应内容(`SAF-LOG-PURITY`) |
| **SAF-LOG-PURITY** | 日志/Redis 写入内容**仅限**:结构化事件 ID、状态机转移、计数、时间戳、脱敏后的请求摘要(request id、状态码、耗时);禁止 `password`/`token`/`secret`/`Authorization` 原文、JMAP 正文、LLM prompt/completion、附件内容 |
| **C-NO-STATEFUL-RECOVERY** | **禁止依赖进程内状态做生产恢复**:任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账(`FLOW-RECONCILE`/`C-REDIS-ONLY-STATE`)实现;进程内缓存仅为性能优化,丢失必须安全可重入 |
---
## 1. 非目标(Non-Goals)
> 以下均为**历史早期参考**,**非目标、不再支持**。不得在实现中复活为运行模式。
| ID | 非目标 | 说明 |
|---|---|---|
| **NG-SERVER-MODE** | `RUN_MODE=server` 常驻运行模式 | 早期 EventSource+长轮询 常驻设计,已删除 |
| **NG-POLLING-SSE** | JMAP EventSource/SSE 长连接订阅 | 与 `C-NO-LONG-CONN` 冲突;实时通道只用 Push 回调 |
| **NG-LONG-POLLING** | Telegram 长轮询运行模式 | 与 `C-NO-LONG-CONN` 冲突;只用 Webhook |
| **NG-SQLITE-PERSIST** | SQLite 会话 / sinceState 本地持久化 | 状态只走外部 Redis(`C-REDIS-ONLY-STATE`) |
| **NG-LOCAL-VOLUME** | `/app/data` 等本地卷持久化 | 无本地持久层(`C-REDIS-ONLY-STATE`) |
| **NG-SERVERLESS-BIND** | 绑定 Cloud Run / Lambda / CF Workers / Deno 等具体平台 | 部署目标是通用 Docker 容器平台;不绑定平台 |
---
## 2. Debian Slim 选型
(保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定)
---
## 3. Rust 多阶段构建与运行
### 3.1 构建阶段
(保留既有 rust:bookworm 构建 + 缓存分层约定;产物为单个静态编译二进制 `stalwart-bot`)
> **实现现状**(阶段0 + 阶段1,`ARCH-STAGE0`/`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1`):自动依赖含 `axum 0.8`(单端口入口)/`serde`/`serde_json`/`thiserror`/`secrecy`/`subtle`(常数时间比较)/`url`/`tokio`/`tracing` 等;**`jmap-client =0.4.2` 已引入**(阶段1,`default-features=false, features=["async","rustls"]`,**禁用 WebSocket feature**,`C-NO-LONG-CONN`);三条入口路由的鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`),**JMAP 只读 adapter(G1/D-G1-1)代码已实现、待真实 `cargo test -- --ignored jmap::` 验证**,其余业务体仍为**占位**;`teloxide`(阶段2)/`redis`(阶段3/4)/`reqwest`(阶段3.5) 尚未引入;配置为环境变量手工解析(`ARCH-CONFIG-ENV`,无 figment/TOML)。CI 门禁见 §8(`cargo fmt --check`、`cargo clippy --all-targets -- -D warnings`、`cargo test`,在 Debian 容器内,`GATE-P0`)。
### 3.2 运行阶段(收敛为单端口)
- `EXPOSE 8080`(遵循 `C-NO-TCP-EXPOSE`,不再暴露 9191 admin 端口)
- `ENV PORT=8080`、`RUN_MODE=webhook`(默认)
- 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口
- **不声明 VOLUME**,无 `/app/data` 本地卷(`NG-LOCAL-VOLUME`)
---
## 4. 运行时用户 / 证书 / TLS 与公网 HTTPS 入口
- 非 root 用户运行(保留既有约定)
- **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、/push/jmap、/reconcile 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。
- 需要在 Stalwart 与 Telegram 两侧登记这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;Stalwart PushSubscription 的 `url` 指向 `/push/jmap`。
- **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。
- Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接)
---
## 5. Secret 管理与环境变量
> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;下表中的业务环境变量是历史阶段说明,不应再注入生产容器。业务密钥通过受保护的 `/api/bootstrap` 或管理员 PUT 写入 Redis,并在成功后热重建客户端。
(保留既有 Secrets 注入约定:禁入镜像、`secrecy` 包裹、日志屏蔽;新增运行模式相关变量)
| 变量 | 必填 | 说明 |
|---|---|---|
| `BOT_TOKEN` | ✅ | Telegram Bot Token |
| `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) |
| `JMAP_SESSION_URL` | ✅ | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):填**服务基地址**(`https://mail.example.com`)或**完整** `…/.well-known/jmap` 均可;代码归一化为 origin/base 后再交 `jmap-client`,**不产生重复路径**。仅 HTTPS;**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) |
| `JMAP_USERNAME` / `JMAP_PASSWORD` | ✅ | **Stalwart 认证 = App Password + Basic**(已确认 `C-AUTH-APP-BASIC`):账号填邮箱,密码填在 Stalwart 生成的**应用专用密码**(可独立吊销/设到期);不用主密码、不用 OAuth |
| `CHAT_ALLOWLIST` | ✅ | 聊天白名单(**硬约束 `SAF-CHAT-ALLOWLIST`**):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝 |
| `REDIS_URL` | ✅ | **外部 Redis(用户托管 + AOF)**(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`):session / dedup / Streams / fuse / sinceState 全部在此;Redis 进程不在本 compose 内 |
| `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) |
| `RUN_MODE` | 默认 `webhook` | `webhook` / `reconcile` 二选一(`NG-SERVER-MODE` 已删除) |
| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer <token>` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) |
| `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session |
| `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) |
| `JMAP_PUSH_VERIFICATION` | ✅ | `/push/jmap` 校验请求体 JSON 字段 `verificationCode`(`SAF-AUTH-JMAP-PUSH`)。须与 Stalwart PushSubscription 的 verification code **完全一致**(`SecretString`) |
| `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) |
AI 授权期限由用户选择(临时一次、1小时、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。
| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |
> **三个入口鉴权变量均为必填(fail-closed,`SAF-AUTH-*`)**:`RECONCILE_TOKEN` / `TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` 缺失即**启动失败**(`Config::from_env()` 报缺失),不存在"未配置则放行"的降级路径。三个入口分别校验:`/reconcile`(Bearer 头)、`/webhook/tg`(secret 头)、`/push/jmap`(Body `verificationCode`);校验失败一律返回 `401`,且在鉴权通过前**不产生任何副作用**。`/healthz` 与 `/ready` 为公开探针,不含敏感信息。
> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。
> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。
---
## 6. 运行模式与短请求模型
### 6.0 Redis-only 配置迁移与 bootstrap 威胁模型(`C-REDIS-ONLY-STATE`)
生产进程接受 `REDIS_URL` 与唯一额外启动密钥 `CONFIG_ENCRYPTION_KEY`(32 字节随机
高熵 hex,仅应用运行时持有);其中 Redis ACL 认证是唯一 bootstrap 信任根;业务
密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap
会话:请求必须以 Redis ACL 密码作为 Bearer 凭据,服务端只做常数时间比较,绝不在
响应、日志或配置值中回显该密码。bootstrap 使用原子 `SET NX` 写入完整业务配置,
竞争请求只有一个成功;无密码 Redis 或认证失败时拒绝初始化。
该 ACL 身份必须具备 `config:business`、`config:outbound` 及 admin-session 键的读写权限,
因为它同时是应用 Redis 连接凭据与空库 bootstrap 根信任。bootstrap 完成后公网业务配置
接口只接受 900 秒 admin session;持有 ACL 密码者仍等同根信任,必须按生产 Redis 凭据
同等级保护,不得把该身份误配置为禁止配置键访问。
成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销
或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存
不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显
任何密钥,配置缺失或业务依赖未就绪时保持 HTTP/SPA 可用但业务路由返回未就绪。
`config:business` 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中)
写入 Redis;应用启动时使用 `CONFIG_ENCRYPTION_KEY` 解密,密钥永不写 Redis、日志、响应
或浏览器存储。该保护只防 Redis 内容泄露,不替代 `rediss`、VPN 或受信网络对 `redis://`
窃听/篡改的防护;公网 Redis 必须使用 TLS/VPN/隧道。
bootstrap 或管理员 PUT 修改 `config:business` 后,服务会先校验并构建全部 JMAP/Telegram/LLM
客户端,再原子替换 worker;构建失败保留旧配置与任务。后续请求即时使用新快照,多实例在
请求边界按 revision 有界检查并尝试刷新;失败时保留旧实例,避免忙循环。
TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链路只能位于受控私网,
并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面
内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。
> 仅两种运行模式;无长连接(`C-NO-LONG-CONN`)。
| RUN_MODE | 命令 | 说明 |
|---|---|---|
| `webhook`(默认) | `stalwart-bot webhook`(或单一入口按 env 分派) | 单进程:TG Webhook handler + JMAP Push 回调 handler + Redis Streams worker + `/reconcile` + health |
| `reconcile` | `stalwart-bot reconcile` | 一次性对账补差后 exit,由外部调度器/容器任务触发 |
### 6.1 入口路由(单端口 `PORT`,全部经平台 HTTPS URL 入站)
```
# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080
GET / 业务配置与运行参数管理 SPA
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session
POST /api/admin/session/revoke Bearer admin session;成功返回 204
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204
POST /webhook/tg TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token]
→ 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径)
POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body verificationCode]
→ 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK(慢任务异步)
POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN]
→ 对账补差 FLOW-RECONCILE
GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息)
GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER)——**不执行** Redis PING / JMAP session / TG getMe;真实 readiness 属后续门禁 GATE-READY-DEPS(公开探针,无鉴权、无敏感信息)
```
> **三入口鉴权(fail-closed,`SAF-AUTH-*`)**:三条写路径必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。
> **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**;**`/ready` 现阶段不检查任何依赖**,不得据此判断 Redis/JMAP/TG 可用性(见 §7 与 design.md §10.0 `GATE-READY-DEPS`)。
> **"公网 HTTPS 入口"是什么**(`C-HTTPS-URL`):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。
### 6.2 Redis Streams / worker(MOD-STREAMS)
- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
- worker(同容器后台 task 或独立 worker 容器,2 选 1 均由 compose/编排决定):
`XREADGROUP → Email/changes → 推送 TG → 推进 sinceState → XACK`
- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE)
- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。
由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`),触发一次对账。
- **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。
- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
- **Redis 丢失恢复**:sinceState 存 Redis(`MOD-SINCESTATE`,AOF 持久化 `C-REDIS-MANAGED-AOF`);即便 Redis 全丢,对账扫描 JMAP(`Email/query` 最近 N 封 + changes)也能重建游标并补发——**事实源在 JMAP,Redis 只是加速层**。
### 6.4 可靠性策略(Reliability)
> 目标:**至少 99.9% 通知可用性**(`NFR-NOTIFY-SLA`),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。
| 机制 | ID | 做法 |
|---|---|---|
| **Streams ACK / 重试** | `MOD-STREAMS` | worker 用消费组 `XREADGROUP`;处理成功才 `XACK`;未 ACK 消息在被认领后重投(at-least-once)。处理失败时**不 ACK**,自然重试;设最大投递次数上限,超限转死信(`XADD` 到 `dlq`)并告警,避免毒丸阻塞 |
| **幂等去重** | `MOD-DEDUP` | 以 `(account, email_id)` 为幂等键,`SET NX`(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下**不重复通知**。TG 侧同理用 `update_id` |
| **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 |
| **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟拉 `Email/changes` 补差,覆盖 Push 丢失/Redis 抖动/冷启动窗口;同时用于 Redis 全丢后的游标重建 |
| **指标 / 告警** | — | 导出指标:Push 到达数、去重命中率、Streams 积压深度(pending)、DLQ 条数、对账补发条数、JMAP 延迟、TG 推送失败率。对"积压持续增长 / DLQ 非空 / 对账补发异常升高"告警。**注**:`/ready` 现为占位 200(`ARCH-READY-PLACEHOLDER`),**尚不反映依赖健康**;待 `GATE-READY-DEPS` 落地后才可用于依赖告警 |
### 6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA)
- **定义**:在外部依赖(Stalwart / Telegram / Redis)可用的前提下,通知链路的可用性目标 ≥ **99.9%**(约每月 ≤43 分钟不可用)。
- **允许的延迟**:用户已确认**接受少量通知延迟**——正常情况下 Push 回调应为秒级;依赖抖动/需对账兜底时,延迟上界为**对账间隔(≤10 分钟)**。
- **非目标边界**:99.9% 是**通知可用性**目标,不含 Stalwart/Telegram/Redis 自身故障时间;三者任一长时间不可用属外部依赖故障,不计入本 bot 的可用性预算(但 bot 应在恢复后经对账自动补齐)。
- **降级行为**:Redis 不可用时,去重/会话退化为进程内短窗口、Push 回调返回非 2xx 交 Stalwart 重试;恢复后由对账补齐。**不因 Redis 抖动而漏发最终通知**(以对账为准)。
---
## 7. 健康检查(通用 HTTP 约定)
- `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义)
- `GET /ready` → **阶段0 为占位 `200`**(`ARCH-READY-PLACEHOLDER`),**不做** Redis PING / JMAP session / TG getMe。真实 readiness 检查(依赖不可用 → `503`)为**后续门禁 `GATE-READY-DEPS`**,随 `redis` 接入且 JMAP 只读 adapter 可用后实现。
- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。
- ⚠️ 在 `GATE-READY-DEPS` 落地前,**不得**把 `/ready` 描述/当作"依赖健康检查",编排探针亦不得依赖其探测依赖。
- Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)
- 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`)
---
## 8. CI 发布与镜像验收
### 8.0 代码门禁(`GATE-P0`,进入阶段1前必须通过)
- `cargo fmt --check`
- `cargo clippy --all-targets -- -D warnings`
- `cargo test`
以上三条**在 Debian `rust:1-slim-bookworm` 容器内执行**(`C-DEBIAN-SLIM`);详见 design.md §10.0。
(其余 CI:镜像构建 → 镜像扫描 → 非 root → health 端点到容器验证;compose 示例收敛为单端口 + 外部 Redis URL + 无 volume)
### 8.1 docker-compose 示例(收敛)
```yaml
services:
bot:
image: stalwart-bot:latest
ports:
- "8080:8080" # 平台 ingress/反代映射 HTTPS → 8080(C-HTTPS-INBOUND)
environment:
PORT: "8080"
RUN_MODE: webhook
# BOT_TOKEN / JMAP_* / CHAT_ALLOWLIST / REDIS_URL
# RECONCILE_TOKEN / TG_WEBHOOK_SECRET / JMAP_PUSH_VERIFICATION(三入口鉴权,均为必填 SAF-AUTH-*)
healthcheck: { test: ["CMD", "stalwart-bot", "health", "--addr", "127.0.0.1:8080"], interval: 30s }
# REDIS_URL 指向外部、已认证的 Redis;不在此 compose 中运行 Redis。
```
> 注意:本示例对接 Compose;真实部署可去掉 `ports` 直接挂到平台 ingress(HTTPS-only)。
### 8.2 镜像与部署验证检查项(红线自检)
以下检查在每次发布前/CI 中执行,用于固化 §0 新增红线:
| # | 检查项 | 关联 ID | 命令/方法 |
|---|---|---|---|
| 1 | **镜像内无数据库引擎**:不装/不含 SQLite、Postgres、MySQL 二进制或数据文件 | `C-NO-DB` | `docker run --rm <image> sh -c 'command -v sqlite3 psql mysql 2>/dev/null; [ -z "$(find / -maxdepth 4 -name "*.db" -o -name "*.sqlite*" 2>/dev/null)" ]'` 应为空 |
| 2 | **容器内不出现本地可写挂载**:compose/k8s 不声明 `volumes:` 用于数据/日志目录 | `C-NO-LOCAL-WRITE` / `NG-LOCAL-VOLUME` | 人工/CI lint 检查 compose 文件、k8s manifest 不含本地 volume 声明 |
| 3 | **日志仅 stdout/stderr**:应用启动参数不得含 file/rolling-file 后端;tracing 配置为 `stdout` | `C-LOG-STDOUT-ONLY` | 检查代码:`grep -rEn 'rolling\|FileAppender\|RollingFileAppender' src/` 应为空;`RUST_LOG` 目标不含 `file:` |
| 4 | **日志/Redis 无敏感数据**:日志字段白名单 + CI 扫描 | `SAF-LOG-PURITY` | 代码评审 + 静态扫描(如 `gitleaks` 扫描 `Redis` 写入调用,验证 `SETEX`/`SET` 参数无密钥/正文/AI 内容) |
| 5 | **重启恢复不依赖进程内状态**:所有"续跑"逻辑必须读 Redis;进程内缓存仅性能优化 | `C-NO-STATEFUL-RECOVERY` | 代码评审:`grep -rn 'static mut\|lazy_static' src/` 不得用于生产恢复路径 |
| 6 | **Redis 由外部提供**:容器不运行 `redis-server`;compose 不启动 Redis 服务 | `C-REDIS-MANAGED-AOF` | `docker run --rm <image> sh -c 'command -v redis-server && exit 1 || exit 0'` 应为 0 |
> 任一检查失败,禁止发布。
---
## 9. 已确认决策与少量待办
### 9.1 已确认决策(不再作为待确认问题)
| ID | 决策 |
|---|---|
| `REQ-SINGLE-ACCOUNT` | **单账户实现**;多账户暂用**多个 bot 实例**(各自 token/配置),不做多账户单实例 |
| `C-AUTH-APP-BASIC` | Stalwart 认证 = **App Password + Basic**(不用主密码、不用 OAuth) |
| `REQ-AI-EXTERNAL-CONSENT` | **仅当用户明确允许时**才把邮件正文发往外部 AI(默认不外发) |
| `C-REDIS-MANAGED-AOF` | Redis 由**用户托管**并**开启 AOF 持久化**(Bot 不自建/不管理 Redis) |
| `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443) |
| `NFR-RECONCILE-INTERVAL` | **外部 Cron 定期 HTTPS POST `/reconcile`,建议 5–10 分钟**;容器不自建定时器 |
| `NFR-NOTIFY-SLA` | **允许少量通知延迟;通知可用性目标 ≥ 99.9%**(边界见 §6.5) |
| `ARCH-LB-WORKER` | **多实例高可用**:免费 Cloudflare Worker 作为**唯一对外入口 + 故障转移**,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10) |
| `SAF-LB-PASSTHRU` | 信任模型 = **透传(A)**:Worker 不改写鉴权信息,**后端必须继续 fail-closed 校验**(小平台无防火墙/ACL,"后端不对公网暴露"不可行) |
| `SAF-RECONCILE-LOCK` | `/reconcile` **不扇出**:用 **Redis 锁**保证同一时刻仅一个实例执行 |
| `MOD-HEALTH-AGG` | Worker 暴露**聚合健康视图**,供外部监控 |
| `NFR-HA-MULTI-INSTANCE` | 双活或主备**均可**;**Redis 单点故障不在本方案范围**(用户外部解决,短暂不可用可接受) |
| `C-NO-DB` | **生产不使用任何数据库**:Redis 是唯一状态存储;应用不自建/不连接第二个数据库 |
| `C-NO-LOCAL-WRITE` | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷 |
| `C-LOG-STDOUT-ONLY` | **日志只写 stdout/stderr**(平台负责采集落盘);禁用文件日志后端 |
| `SAF-LOG-PURITY` | **日志与 Redis 写入不得包含**:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要 |
| `C-NO-STATEFUL-RECOVERY` | **禁止依赖进程内状态做生产恢复**:重启恢复一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅为性能优化,丢失必须安全可重入 |
### 9.2 仍需确认(部署相关,仅此)
| # | 待确认 | 关联 ID |
|---|---|---|
| **Q-DEP-A** | 平台 URL / 域名与证书由谁配置(平台自动证书 or 自管反代),以及 4 条路径的可达性验证方式 | `C-HTTPS-URL` |
| **Q-DEP-B** | 外部调度器具体选型(系统 crontab / k8s CronJob / CI scheduled / 第三方 cron)—— 仅影响运维方式,不改变架构 | `NFR-RECONCILE-INTERVAL` |
> 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。
---
## 10. 多实例高可用与 Worker 前置负载均衡(`ARCH-LB-WORKER`)
> 目标:在**多个 serverless 平台各部署一份相同镜像**(各自不同的 HTTPS 入口),最前面用一个**免费 Cloudflare Worker** 作为**唯一对外入口 + 故障转移**;所有实例共享**同一个外部 Redis**。双活(round-robin)或主备(active-standby)**均可**。业务代码无需改动——之所以可行,是因为既有设计已满足前提(见下)。
### 10.1 拓扑(`ARCH-LB-WORKER`)
```
Telegram setWebhook ─┐
Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯一登记 URL(C-LB-SINGLE-REG-URL)
外部 Cron ───────────┘ │ Cloudflare Worker(免费)
├─▶ https://a.<platform1>/… ─┐
└─▶ https://b.<platform2>/… ─┼─▶ 同一个外部 Redis(C-REDIS-ONLY-STATE)
(同一镜像、同一配置、同一组 secret)─┘
```
- **只登记 Worker 的稳定 URL**(`C-LB-SINGLE-REG-URL`):Telegram `setWebhook`、Stalwart `PushSubscription.url`、外部 Cron **都指向 Worker**(`https://lb.example.com/webhook/tg`、`/push/jmap`、`/reconcile`)。后端易变的平台入口**不对外登记**。
- 后端 = 与 §3 相同的镜像、相同 env、相同 Redis;**不引入长连接**(Worker 纯请求-响应,`C-NO-LONG-CONN`)。
### 10.2 为什么可直接支持多实例(既有前提)
| 前提 | ID | 作用 |
|---|---|---|
| 状态仅外部 Redis | `C-REDIS-ONLY-STATE` | 任意实例可服务任意请求,**无需粘性会话** |
| 幂等去重 | `MOD-DEDUP` | 重复/重试投递**不会重复通知**,故"多实例 + 重试"安全 |
| 对账补差 | `FLOW-RECONCILE` | 兜住 Push 丢失 / cold-start 窗口 |
> 因此 LB/HA 是**纯运维拓扑**变化;领域逻辑与渠道层无需改动。
### 10.3 信任模型 = **透传(A)**(`SAF-LB-PASSTHRU`)
- Worker **原样转发** header / body / `verificationCode`,**不做鉴权改写**。
- **后端必须继续 fail-closed 校验**三条入口(`SAF-AUTH-*`)——**不可省**。原因:小平台通常**不提供防火墙/ACL**,后端 HTTPS 入口可能被公网直连;因此"仅靠 Worker 防护"不成立(方案 B「后端不对公网暴露」在本场景**不可行**)。
- **多实例必须共享同一组 secret**(`C-LB-SHARED-SECRETS`):`TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` / `RECONCILE_TOKEN` 在所有实例上**完全一致**,否则请求落到不同实例会随机 `401`(对端发来的值只有一个)。
### 10.4 路由与故障转移
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。所有管理 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` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。
- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。
- **超时预算**(`LB_REQUEST_TIMEOUT_MS`,默认 10s)> 最坏 cold start。
- **重复兜底**:Worker 重试 + Telegram 重投造成的重复由 `MOD-DEDUP` 吸收;**幂等键 TTL 必须覆盖** Worker 重试与 Telegram 重投窗口。
- **不代理 Redis/JMAP**(`C-NO-DB` / `C-REDIS-ONLY-STATE`):Worker 只做请求转发,后端各连自己的 Redis;Worker 不做数据库侧检查。
- **无长连接**(`C-NO-LONG-CONN`):纯请求-响应,body 一次性 `arrayBuffer` 回灌;无 WS/SSE/长轮询。
### 10.5 各入口差异(重要)
| 入口 | 是否可扇出/重复 | 处理 |
|---|---|---|
| `/webhook/tg` | 可安全重复(`update_id` 去重) | Worker 选一实例;重试安全 |
| `/push/jmap` | 可安全重复(`(account,email_id)` 去重) | Worker 选一实例 |
| `/reconcile` | ❌ **不可扇出** | **Redis 锁**(`SAF-RECONCILE-LOCK`)保证**同一时刻仅一个实例执行**,避免重复对账 |
| Streams worker | 用**相同消费组名**(`MOD-STREAMS-GROUP`) | Redis `XREADGROUP` **自动分摊**给多实例;at-least-once 下同一消息不会重复处理 |
### 10.6 聚合健康视图(`MOD-HEALTH-AGG`)
- Worker 额外暴露**聚合端点**,报告各后端存活数与整体可用性,供外部监控/告警使用;比单实例探针更有意义。
### 10.7 边界与不在本方案范围
- **Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`):由用户在外部解决;其短暂不可用**可接受**(Redis 全丢可由 `FLOW-RECONCILE` 从 JMAP 重建 sinceState;dedup/会话丢失只导致**少量重复通知**,符合 `NFR-NOTIFY-SLA`)。
- **Cloudflare Worker 免费额度**:预期足够(requests/day、subrequest、CPU/超时上限需上线后实测确认)。
- 不引入长连接、不引入平台特定绑定(`C-NO-LONG-CONN`/`NG-SERVERLESS-BIND`)。
### 10.8 部署 Cloudflare Worker(`ARCH-LB-WORKER` 具体步骤)
代码位于仓库子目录 [`cloudflare-worker/`](../cloudflare-worker/),**非本 Rust 二进制的一部分**;Worker 只做请求转发,不运行 Redis/JMAP,不引入数据库(`C-NO-DB`)。
**前置**
- 已按 §3 部署 ≥1 个后端镜像(多实例则部署 2+ 个),各后端 `PORT`/路由同 §6.1。
- 后端各实例 env 必须共享同一组 secret(`C-LB-SHARED-SECRETS`)。
- 已注册 Cloudflare 账号与 Workers 计划。
**配置**(所有值通过 `wrangler secret` 注入,**禁止**写入 `wrangler.toml` 或 `git` 明文)
```bash
cd cloudflare-worker
npm install --no-audit --no-fund
npx wrangler secret put BACKEND_ORIGINS_JSON # JSON 数组,见下方示例
# 可选(带默认值)
npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 默认 10000ms
npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2(首次 + 1 次故障转移)
npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms
```
`BACKEND_ORIGINS_JSON` 示例(**仅允许 `https://` origin**,其它会被 Worker 启动即 503,`C-HTTPS-INBOUND`):
```json
[
{"url":"https://stalwart-bot-a.example.com","weight":100},
{"url":"https://stalwart-bot-b.example.com","weight":100}
]
```
**部署与验证**
```bash
cd cloudflare-worker
npx wrangler deploy --env production
# 验证 LB 健康聚合
curl https://<your-worker>.workers.dev/healthz
```
**Telegram / Stalwart / Cron 登记的唯一 URL** 改为 `https://<your-worker>.workers.dev/webhook/tg` 等(`C-LB-SINGLE-REG-URL`);**各后端平台的 HTTPS 入口不再对外登记**(小平台入口可能被公网直连,故**后端鉴权不可省**,`SAF-LB-PASSTHRU`)。
**验证(发布前必做)**
1. `cd cloudflare-worker && npm test`(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。
2. `npx wrangler deploy --env production` 后 `curl /healthz` 返回 200 且 `available ≥ 1`。
3. `curl -X POST https://<worker>/webhook/tg -H "Content-Type: application/json" -d '{"test":1}'` 应得到后端返回(非 503)。
4. `curl -X POST https://<worker>/unknown` 应得到 404;`curl -X GET https://<worker>/reconcile` 应得到 405。
5. 日志仅出现 `method/path/origin/失败类别`,**绝不**含 header/body/secret(`SAF-LOG-PURITY`)。
**边界说明**
- Worker **未**实现 `/reconcile` 的 Redis 锁(`SAF-RECONCILE-LOCK`)——该锁由**后端**在 `/reconcile` 处理器内部执行;Worker 只做透传。详见 `SAF-RECONCILE-LOCK` 条目。
- Worker **不代理** Redis、不检查数据库侧可用性(`C-NO-DB` / `C-REDIS-ONLY-STATE`)。
- Worker **不落地** JMAP/Telegram 的 secret(`TELEGRAM_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION` 等属于**后端**容器,见 `deployment.md §5` 与 `.env.example`)。
---
## 附:稳定 ID 引用索引
> 跨文档稳定 ID 的**唯一权威索引表**在 `AGENTS.md` §7「跨文档引用索引表」。
> 本文使用到的 ID(`C-*` / `NG-*` / `MOD-*` / `FLOW-*` 等)均在其中登记定义点位置与一句话说明;内容搬家时**只改 AGENTS.md 索引表**的"文件/锚点"列,所有引用本身零改动。

808
docs/design.md Normal file
View File

@ -0,0 +1,808 @@
# Stalwart JMAP ↔ Telegram Bot — Rust 方案设计
> 状态:**调研与方案设计**(未写代码)
> 作者:Cowork(team: stalwart-bot)
> 日期:2026-09-21
> 目标读者:Codex CLI(lead)、后续 AI coding agent、最终用户评审
>
> **相关文档**(职责分离,避免重复堆砌):
> - [AGENTS.md](../AGENTS.md) — 给后续 AI coding agent 的硬性安全边界、实现顺序、禁止事项、测试验收与文档引用关系
> - [docs/deployment.md](deployment.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与 4 项待确认
>
> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维、Docker/通用容器平台细节与给 coding agent 的通用操作指令已分别移至 `deployment.md` 与 `AGENTS.md`。
---
## 0. 文档导航
1. [任务与范围](#1-任务与范围)
2. [jmap-client 能力分析](#2-jmap-client-能力分析)
3. [认证与邮箱操作适配](#3-认证与邮箱操作适配)
4. [Telegram Rust 框架选型](#4-telegram-rust-框架选型)
5. [整体架构与数据流](#5-整体架构与数据流)
6. [模块划分](#6-模块划分)
7. [配置与安全](#7-配置与安全)
8. [错误处理与可观测性](#8-错误处理与可观测性)
9. [测试策略](#9-测试策略)
10. [分阶段实施计划](#10-分阶段实施计划)
11. [待确认问题(产品/架构类)](#11-待确认问题产品架构类)
12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退)
> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并收敛;剩余待办仅 `Q-DEP-A`/`Q-DEP-B`,见 deployment.md §9。
---
## 1. 任务与范围
### 1.1 目标
构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**:
- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。
- 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。)
- 单用户或多账户部署(默认面向单账户自托管场景)。
### 1.2 范围(本阶段)
- ✅ 调研 + 方案设计(本文档)
- ❌ 不创建/修改项目代码、不初始化 cargo 工程
- ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项
### 1.3 调研依据
- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研)
- 项目目录:`/home/okabe/Repo/stalwart-bot/`(greenfield,首次构建需装 Rust 工具链,见 §10.0 与 AGENTS.md)
---
## 2. jmap-client 能力分析
来源:直接阅读 `stalwartlabs/jmap-client` 仓库 `src/lib.rs`、`src/client.rs`、`src/email/`、`src/email_submission/`、`src/event_source/`、`Cargo.toml`、`README.md`、`examples/`。
### 2.1 crate 概况
| 项 | 值 |
|---|---|
| crate 名 | `jmap-client`(crates.io) |
| 协议覆盖 | JMAP Core (RFC 8620)、Mail (RFC 8621)、WebSocket (RFC 8887)、Sieve (draft-12) |
| 异步运行时 | tokio + reqwest |
| 许可证 | Apache-2.0 OR MIT |
| `forbid(unsafe_code)` | 是(lib.rs 顶部声明)✅ |
| 默认 features | **0.4.2 实测 `default = ["async", "websockets", "aws_lc_rs"]`**(含 WebSocket 栈 `tokio-tungstenite`)。⚠️ `default-features = true` 会**隐式启用 WebSocket**,违反 `C-NO-LONG-CONN`。**本项目实际选用 `default-features = false, features = ["async", "rustls"]`(无 `websockets`)**,以 `Cargo.toml` 为准。 |
### 2.2 模块结构(`src/`)
```
lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举
client.rs Client:认证、connect、session、send_request、event_source、ws
core/ request / response / get / set / query / query_changes / changes / error / session
email/ mod + helpers(email_get/email_query/email_import/email_copy/email_set/email_parse…)
email_submission/ helpers(email_submission_get/query/set → 发送邮件 + 状态查询)
mailbox/ helpers(mailbox_create/query/get/set/destroy…)
thread/ thread/get
identity/ Identity(发件身份)get/set
blob/ blob 上传/复制(附件)
sieve/ SieveScript(服务端过滤脚本)
vacation_response/
principal/ Principal(账户/共享)
push_subscription/ PushSubscription(HTTP push 回调注册)
event_source/ SSE 流:mod / parser / stream
client_ws/ WebSocket 客户端(feature = "websockets")
```
### 2.3 关键能力映射到本 Bot
| Bot 需求 | jmap-client API | 备注 |
|---|---|---|
| 登录/会话 | `Client::new().credentials(...).connect(url)` | 支持 Basic 与 Bearer;connect 解析 session URL、capabilities |
| 列文件夹 | `mailbox_query` / `mailbox_get` | 带 `role` 可识别 INBOX/重要/草稿等 |
| 列邮件 | `email_query`(Filter + Comparator + anchor 分页) | Filter: `subject`/`from`/`to`/`in_mailbox`/`has_keyword`/`after`/`before`… |
| 读邮件正文 | `email_get` + `Property` 选择 | `BodyStructure`/`BodyValues`/`Preview`/`TextBody`/`HtmlBody` |
| 读附件 | `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` 高亮 | |
| 实时通知(**唯一**通道) | `push_subscription` 注册 HTTPS 回调 → `StateChange` | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);+ 外部 Cron 对账兜底 |
| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 |
### 2.4 认证机制(`client.rs`)
- `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。
- `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。
- `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。
- 支持自定义 `reqwest::Client`(`Client::new().client(reqwest_client)`):可注入代理、TLS 配置、超时、UA。
- 支持 `accept_long_responses` / `event_source(ping, ..)` 用于长连接保活。
### 2.5 错误模型(`Error` 枚举)
```
Transport(reqwest::Error) 网络/TLS/超时
Parse(serde_json::Error) 序列化
Internal(String) 客户端内部
Problem(Box<ProblemDetails>) JMAP problem-details
Server(String)
Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…)
Set(SetError<String>) /set 级别错误(每条记录的 creation/update/destroy 失败)
WebSocket(...) ws 错误(feature 开启时)
```
- `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。
### 2.6 实时通道决策(已定,见 deployment.md)
> 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。
> 据此**唯一通道 = JMAP Push HTTPS 回调**(`push_subscription` + verificationCode 握手)+ 外部 Cron 对账兜底。
> EventSource/SSE 与 WebSocket 均标记为**非目标**(`NG-POLLING-SSE`),仅保留下表作 crate 能力调研参考。
| 通道 | 是否采用 | 备注 |
|---|---|---|
| **Push Subscription(HTTP 回调)** | ✅ **唯一采用** | 短请求模型,契合无状态 + 无长连接;`jmap-client` 提供 create/verify/update/destroy |
| EventSource/SSE | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突(`NG-POLLING-SSE`) |
| WebSocket(RFC 8887) | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突;且需服务端支持 |
对账兜底:外部 HTTPS Cron 周期调 `/reconcile`(deployment.md `FLOW-RECONCILE`),用 `Email/changes` + Redis `sinceState` 补差,兼做 Redis 丢失后的游标重建。
---
## 3. 认证与邮箱操作适配
### 3.1 认证策略(已确认:App Password + Basic)
- **确认采用**(`C-AUTH-APP-BASIC`):`Credentials::Basic`(Stalwart 账户邮箱 + **应用专用密码 App Password**)。App Password 可独立吊销/设到期,不用主密码。配置注入,不入代码。
- **不采用**:OAuth/OIDC `Bearer`(无 OIDC 需求时增加复杂度,不选);主密码 Basic。
- **安全**:密码仅存配置或密钥管理器;运行期用 `secrecy::SecretString` 包裹,日志永不打印明文(见 §7.2)。
- 运行期注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
- **单账户**(`REQ-SINGLE-ACCOUNT`):本实例只接一个 Stalwart 账户;多账户 = 部署多个 bot 实例。
### 3.2 邮箱操作适配层(JMAP ↔ Bot 语义)
建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词:
| Bot 动词 | 封装函数 | 内部 JMAP |
|---|---|---|
| `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]) |
| `read_email(id, want_body)` | → `EmailBody { text, html, attachments }` | `email_get`([BodyStructure, BodyValues, BlobIds]);**多 part 原文**(`REQ-JMAP-RAW-MULTIPART`):按 `text_body` 顺序筛选"有 `part_id` 且 `bodyValue`"的 part 后**拼接**为 `text`;若无可用部分 → 返回**明确错误**(不静默返回空串)。附件用 `Blob/get` |
| `send_email(to, subject, body, attachments)` | → `EmailId` | ① `email_import`/`email_set` 建 draft ② `email_submission_set`(onSend) |
| `set_flag(id, keyword)` | → () | `email_set` keywords |
| `search(query)` | → `Vec<EmailSummary>` | `email_query`(Filter::and[…]) + `SearchSnippet/get` |
> 设计要点:`email_query` 的 `anchor`+`position` 分页是 JMAP 标准做法,比传统 offset 更稳;`sinceState` + `changes` 用于增量同步,避免重复拉全量。
---
## 4. Telegram Rust 框架选型
### 4.1 候选评估
| 框架 | crate | 维护状态 | 特性 | 适配度 | 结论 |
|---|---|---|---|---|---|
| **teloxide** | `teloxide` | 活跃,最新 0.17,下载量大 | dptree 分发、对话 FSM、`webhooks`+`webhooks-axum`、Redis 会话存储、`throttle`、`macros`、`tracing`、`rustls` | ★★★★★ | **首选** |
| grammers | `grammers` / `grammerslib` | 维护一般 | MTProto(非 Bot API),无需 Telegram Bot Token | ★★ | 仅在不能用 Bot API 时 |
| telegram-bot (旧) | `telegram-bot` | 基本停更 | reqwest + futures | ★ | 不推荐 |
### 4.2 选定 teloxide 的理由
1. 与 jmap-client 同为 tokio + reqwest 生态,运行时与 TLS 栈(rustls)可复用。
2. 内建 Dispatcher + UpdateKind 枚举匹配命令,与 Bot 的命令路由天然契合。
3. 支持 `webhooks-axum`(生产 Webhook 形态;**不使用长轮询** `NG-LONG-POLLING`)与 Redis 会话存储(记住用户上下文:当前选中的文件夹/分页游标;不用 SQLite)。
4. `throttle` feature 天然契合 Telegram 的 30 msg/s 速率限制。
5. `tracing` feature 与本项目观测性统一。
6. `macros` feature 可用 `#[teloxide::command]` 自动解析命令参数,减少样板。
### 4.3 teloxide 关键 feature 选择(**计划,阶段2 引入**)
> ⚠️ 阶段0 **未引入 teloxide**(`ARCH-DEPS-STAGE0`);下方为阶段2 引入时的目标 feature 集,不得据此认为 `Cargo.toml` 已含 teloxide。
```
# 阶段2 计划(尚未加入 Cargo.toml)
teloxide = { version = "0.17", features = [
"macros", "rustls", "rustls-native-roots", # TLS 与 jmap-client 一致
"redis-session", # 无状态会话:走外部 Redis(deployment.md C-REDIS-ONLY-STATE)
"throttle", # 遵守 TG 速率限制
"tracing",
] }
# 部署形态 = Webhook(非长轮询,deployment.md NG-LONG-POLLING);按需启用 "webhooks-axum"
```
### 4.4 备选/降级方案
- 若 teloxide 升级/破坏性改动 → 可退到直接基于 `teloxide-core`(更薄、API 稳定)。
- 若需更高吞吐(多账户)→ 用 `webhooks-axum` + 共享 `axum::Router`(与阶段0 的单端口 axum 入口叠加)。
---
## 5. 整体架构与数据流
### 5.1 高层拓扑
```
┌───────────────────────────────────────────────────┐
│ Telegram │
└───────────────────────┬───────────────────────────┘
Webhook(HTTPS) │
┌───────────────────────▼───────────────────────────┐
│ HTTP 入口 (axum/teloxide-webhooks,单端口 PORT) │
│ POST /webhook/tg · POST /push/jmap · │
│ POST /reconcile · GET /healthz /ready │
│ GET / + admin session + config APIs (管理 SPA) │
└───────────────────────┬───────────────────────────┘
命令 │ 通知 ↓(Redis Streams worker)
┌───────────────────────▼───────────────────────────┐
│ bot 层 (handlers) │
│ /list /read /send /search /folders /flag ... │
└───────────────────────┬───────────────────────────┘
领域 │
┌───────────────────────▼───────────────────────────┐
│ jmap 适配层 (JmapService) │
│ list_folders/list_emails/read_email/send_email… │
│ + sinceState 缓存 → 外部 Redis (MOD-SINCESTATE) │
└───────────────────────┬───────────────────────────┘
JMAP │ HTTPS(短请求)
┌───────────────────────▼───────────────────────────┐
│ Stalwart JMAP Server │
│ session_url / email/* / mailbox/* / submission/* │
└───────────────────────▲───────────────────────────┘
Push 回调(HTTPS)│ + 外部 Cron 对账(FLOW-RECONCILE)
┌───────────────────────┴───────────────────────────┐
│ push handler → Redis Streams(MOD-STREAMS) │
│ worker: Email/changes → 通知 → 发往 TG → 推进游标│
└───────────────────────────────────────────────────┘
```
> 部署形态 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`):
> Telegram 仅 Webhook;JMAP 实时仅 Push 回调;慢任务异步入 Redis Streams;对账由**外部 HTTPS Cron** 触发。
> EventSource/SSE 与长轮询均为非目标(`NG-POLLING-SSE`/`NG-LONG-POLLING`)。
> 渠道解耦(见 §5.2):领域层(`domain/`)与渠道层(`channel/`)以领域 Command / Notification 交互,不出现 Telegram/teloxide 类型;钉钉/飞书仅保留扩展位,不提前实现。
### 5.2 渠道抽象与多通道扩展策略
- **现状**:首个(也是当前唯一)渠道是 Telegram。目标是保留平行扩展钉钉/飞书的能力,**但不提前实现**。
- **原则**:领域层与渠道层解耦——邮件/JMAP/AI/意图状态机**不得依赖 Telegram 类型**。
- **抽象(不过度设计)**:
| 抽象 | 职责 | 首个实现 |
|---|---|---|
| `Channel` | 渠道生命周期:接收输入、分发命令、配置端点 | `TelegramChannel`(teloxide Dispatcher) |
| `Notifier` | 主动推送:把领域 `Notification` 发送到用户 | `TelegramNotifier`(teloxide requester) |
| `MessageAdapter` | 领域数据 ↔ 渠道消息渲染(文本/按钮/转义) | `TelegramMessageAdapter`(`render.rs`) |
- 领域与渠道之间用**领域 Command / 领域 Notification** 数据结构传递,渠道只在边缘做适配(解析→领域 Command;领域 Notification→渲染)。
- **约束**:
- `jmap` / `llm` / 意图状态机 / `notify::core` 的公开接口只接受/返回领域类型;
- `teloxide` 类型只允许出现在 `channel/telegram/` 内;
- 新增渠道 = 新 adapter + 装配,不改领域层。
- **不做什么**:不定义多态配置注册表、不预先抽象"渠道能力矩阵"、不建 plugins 机制;按需再演进(YAGNI)。
### 5.3 运行模型(短请求,无长连接)
- 单一二进制 `stalwart-bot`,`#[tokio::main]`。
- 启动时:
1. 加载配置(`config::Config`)。
2. 构造 `JmapService`(`Client::connect` 完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(`MOD-SINCESTATE`)。
3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开探针(`SAF-PROBE-PUBLIC`;**`/ready` 现阶段为占位 200**,`ARCH-READY-PLACEHOLDER`)。
4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。
5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。
- 通知发送与命令处理共享 `Arc<JmapService>`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。
- **多实例与负载均衡(部署形态,`ARCH-LB-WORKER`)**:由于状态全在外部 Redis(`C-REDIS-ONLY-STATE`)且投递幂等(`MOD-DEDUP`),**同一镜像可跨多个 serverless 平台实例化**,前面用免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`);`/reconcile` 用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`),Streams 用同一消费组自动分摊(`MOD-STREAMS-GROUP`)。详见 deployment.md §10。**领域/渠道逻辑无需改动。**
- **生产红线(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`,详见 deployment.md §0 / §8.2)**:
- **生产不使用任何数据库**(`C-NO-DB`):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。
- **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用文件日志后端。
- **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;**禁止**密钥原文、JMAP 邮件正文、AI 请求/响应、附件内容。
- **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账实现;进程内缓存仅为性能优化,丢失必须安全可重入。
- 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。
- **阶段0 现状**(`ARCH-STAGE0`):完成 ①加载配置(env)、③HTTP 入口路由,并**已实现三入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed)**;业务体仍为无副作用占位。②④(JmapService / Redis Streams worker)为阶段1/4 目标,尚未实现。`GATE-P0` 未全项通过前**不应暴露公网**。
### 5.4 数据流:新邮件推送(关键路径,FLOW-NEW-MAIL)
```
Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state})
→ 校验 verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
→ email_get([From, Subject, Preview, ReceivedAt, HasAttachment])
→ notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文)
→ channel::Notifier.send(chat, notification)(Telegram adapter 渲染并发送)
→ 更新 sinceState(写外部 Redis)→ XACK
→ 若 Redis 丢失:由外部 Cron 对账(FLOW-RECONCILE) 重建游标并补发
```
> 关键:`Email/changes` 在 `created` 列出新建邮件 id,避免全量 `email_query`;`sinceState` 存外部 Redis(`C-REDIS-ONLY-STATE`),Redis 丢失时由对账从 JMAP 重建,**事实源在 JMAP**。
> 通知只含发件人/主题/时间(+附件数),正文绝不出现在通知里(见 AGENTS.md 安全边界 `SAF-NOTIFY-META`)。
> 无长连接:Push 为短请求回调,对账由**外部 HTTPS Cron**触发(`C-NO-LONG-CONN`),非容器内自持定时器。
### 5.5 数据流:命令 `/read <seq>`(长邮件分支)
```
TG /read 3 → handler 取会话里的 folder+page 游标
→ JmapService.list_emails(folder, page=3)
→ 取第 3 封 id → read_email(id, body=true) # JMAP 直取,绝不经 LLM
→ 格式化:
正文 ≤ long_email_char_limit(默认 4000) → 正常发送(HTML escape)
正文 > 阈值 → 不发全文,发 Preview(120) + 发件人/主题/附件概览 + 选项:
"[AI 总结](约300字,需确认) / [继续查看原文(截断,标注‘完整请电脑查看’)]"
→ reply
("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3)
```
### 5.6 会话状态机(对话 FSM)
命令路由基于 teloxide `Dialogue`/对话 FSM(多步交互如 `/send`、`/summarize` 确认)。AI 与附件相关流程必须经过显式确认态:
| 状态 | 含义 | 进入 | 离开 |
|---|---|---|---|
| `Idle` | 空闲 | 任意完成态 | 收到消息 |
| `AwaitClarify` | 目标/意图不明,等用户选择 | 意图或邮件目标不唯一 | 用户给出明确选择 |
| `AwaitConfirm` | 等确认(AI 分析 / 附件下载) | 用户发起分析/下载但未确认 | 确认 / 取消 |
| `Analyzing` | AI 请求 in-flight | 用户确认分析 | 成功 / 失败 |
| `AwaitFallback` | AI 失败,等确认回退 | 连续 3 次失败熔断 | 用户确认 / 取消 |
不变量:`AwaitConfirm`/`Analyzing`/`AwaitFallback` 涉及 AI 或附件下载,**未到确认态不得调用 LLM 或拉取附件**。会话状态为短期状态,统一走**外部 Redis 短期 TTL**(`C-REDIS-ONLY-STATE`;不使用 SQLite,丢失可接受),运维 TTL 见 deployment.md(`C-REDIS-ONLY-STATE`)。
> 渠道中立:FSM 状态与事件用领域类型(`domain::state`),不依赖 teloxide;teloxide `Dialogue` 仅作为 Telegram adapter 的会话存储实现。
---
## 6. 模块划分
目标 cargo 工程结构(**阶段0 已落地** `main.rs`/`config.rs`/`error.rs`/`domain.rs`/`channel.rs`/`notify.rs`;下方 `jmap/`、`llm/`、`channel/telegram/`、`notify/*` 为**后续阶段目标**,详见 §10):
```
stalwart-bot/
├── Cargo.toml
├── .gitignore # 排除 target/ 等(阶段0 P0 门禁 GATE-P0)
├── src/
│ ├── main.rs # tokio main:装配置;按 RUN_MODE 分派 webhook / reconcile
│ ├── config.rs # 环境变量解析(ARCH-CONFIG-ENV:std::env,非 figment/TOML)
│ ├── error.rs # BotError 统一错误(见 §8)
│ ├── domain/ # 领域层(不依赖任何渠道类型,见 §5.2)
│ │ ├── command.rs # 领域 Command(List/Read/Analyze/Confirm…,渠道无关)
│ │ ├── notification.rs # 领域 Notification(NewEmail/ResultMsg/ConfirmRequest…)
│ │ ├── state.rs # 会话状态机(§5.6:Idle/AwaitClarify/AwaitConfirm/Analyzing/AwaitFallback)
│ │ ├── jmap/ # JmapService(领域封装,§3.2)—— 阶段1+
│ │ │ ├── client.rs # 包装 jmap_client::Client(构造、重连、自定义 reqwest)
│ │ │ ├── model.rs # Folder / EmailSummary / EmailBody 领域模型
│ │ │ └── state.rs # mailbox role→id 缓存、sinceState 游标(写外部 Redis,MOD-SINCESTATE;不用 SQLite/本地卷)
│ │ └── llm/ # LlmService + policy + fallback + audit(§12.1)—— 阶段3.5
│ ├── channel/ # 渠道层(每渠道一个 submodule,见 §5.2)
│ │ ├── mod.rs # Channel / Notifier / MessageAdapter traits(thin)—— 阶段0 边界已落地
│ │ └── telegram/ # 首个也是当前唯一渠道(adapter #1)—— 阶段2+
│ │ ├── mod.rs # TelegramChannel / TelegramNotifier 装配(teloxide Dispatcher)
│ │ ├── commands.rs # 领域 Command ↔ teloxide 命令映射(/folders /list /read /send /search /flag)
│ │ ├── render.rs # TelegramMessageAdapter:领域 Notification → TG 消息/行内按钮(HTML escape!)
│ │ └── session.rs # teloxide 会话键(当前 folder/page/last_email_id)
│ │ # 预留扩展位:dingtalk/、feishu/(不实现,见 §5.2)
│ ├── notify/
│ │ ├── core.rs # 新邮件处理:JMAP 增量 → 领域 Notification(渠道无关)
│ │ ├── push_handler.rs # POST /push/jmap:校验 verificationCode → 去重 → 入 Redis Streams → 2xx
│ │ ├── worker.rs # Redis Streams 消费:Email/changes → 通知 → 推进 sinceState → XACK
│ │ └── reconcile.rs # /reconcile:外部 Cron 触发对账补差(FLOW-RECONCILE,Redis 丢失恢复)
│ └── util/
│ ├── retry.rs # 指数退避(Transport/RateLimit/ServerUnavailable)
│ └── shutdown.rs # CancellationToken 协调
└── tests/
├── jmap_mock.rs # 对 JmapService 的契约测试(mock Push 回调 + changes 响应)
├── channel_mock.rs # MockChannel/Notifier 契约:领域流程不依赖 Telegram
└── telegram_dispatch.rs # teloxide 测试模式(mock Bot,仅 channel::telegram)
```
### 6.1 模块职责矩阵
> 阶段0 已落地:`main` / `config` / `error` / `domain` / `channel` / `notify`(axum 入口,见 §10.0)。阶段1 起 `jmap-client 0.4.2` **已引入**(`ARCH-DEPS-STAGE1`)。下表含**后续阶段**将引入的模块与依赖(teloxide / redis / reqwest 等**尚未加入 Cargo.toml**,标注「计划」)。
| 模块 | 依赖 | 输出 | 可测性 | 现状 |
|---|---|---|---|---|
| `config` | 标准库 `env`(`ARCH-CONFIG-ENV`) | `Config` 结构 | 纯函数,易测 | 阶段0 已实现(手工 `from_env`,无 figment) |
| `domain::jmap`(含 `client`) | **jmap-client 0.4.2(已引入)** | 领域动词(§3.2);`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 阶段1(G1/D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**) |
| `domain::llm` | reqwest/axum(计划) | 摘要/意图/草稿 | mock OpenAI 端点 | 阶段3.5 |
| `domain::state` | — | 意图路由 + 会话 FSM(零渠道依赖) | 表驱动纯单测 | 阶段3+ |
| `channel`(traits) | — | Channel/Notifier/MessageAdapter | 契约测试(MockChannel) | 阶段0 边界已实现 |
| `channel::telegram` | teloxide(计划) | TG adapter:事件→领域 Command;领域 Notification→渲染 | teloxide mock Bot / MockChannel | 阶段2+ |
| `notify`(入口 + Push/worker/reconcile) | axum(已实现占位);Redis Streams(计划) | 路由占位 → 增量 → 领域 Notification | 驱动假 StateChange 流 | 阶段0 路由占位;阶段4 实现 |
| `util::retry` | — | 重试策略 | 纯逻辑 | 阶段1+ |
---
## 7. 配置与安全
### 7.1 历史配置读取(已迁移至 Redis)
> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis
> 的首次配置必须通过 Redis ACL 密码认证的一次性 bootstrap/admin 会话完成;不得
> 提供未鉴权写入口。ACL 密码仅用于常数时间 bootstrap 校验,不回显、不记录、不写入
> 业务配置。bootstrap 成功后管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务
> token 从 Redis 在启动时装载,密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子
> 替换客户端,后续请求即时使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,
> 阶段0列表仅作为历史兼容说明。
> **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。
| 变量 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) |
| `RUN_MODE` | 否 | `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除) |
| `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) |
| `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) |
| `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) |
| `JMAP_SESSION_URL` | 是 | — | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):可填**服务基地址** `https://host[:port]` 或**完整** `…/.well-known/jmap`;代码归一化为 origin/base 后再交 `jmap-client`(其自动追加 `.well-known/jmap`),**不得重复路径**。仅 HTTPS、**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) |
| `JMAP_USERNAME` | 是 | — | Stalwart 账户(邮箱) |
| `JMAP_PASSWORD` | 是 | — | **App Password**(`C-AUTH-APP-BASIC`;`SecretString`) |
| `JMAP_PUSH_VERIFICATION` | 是 | — | `/push/jmap` 鉴权:请求体 `verificationCode`(`SAF-AUTH-JMAP-PUSH`;`SecretString`) |
| `REDIS_URL` | 是 | — | 外部 Redis(用户托管 + AOF,`C-REDIS-MANAGED-AOF`;`SecretString`) |
| `RECONCILE_TOKEN` | 是 | — | `/reconcile` 鉴权:`Authorization: Bearer`(`SAF-AUTH-RECONCILE`;`SecretString`)。`/reconcile` 路由始终挂载,故**必填** |
| `ACCOUNT_ID` | 否 | 空 | 单账户(`REQ-SINGLE-ACCOUNT`);留空则取 session 主账户 |
| `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) |
| `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) |
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible |
| `LLM_MAX_RETRIES` | 否 | `3` | 熔断阈值(`REQ-AI-FUSE`) |
| `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) |
> 阶段0 `config.rs` 已建立上述字段骨架(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`),未使用的占位以局部 `#[expect(dead_code, reason="稳定ID+阶段0占位")]` 标注。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。
> **入口鉴权三密钥(`TG_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION`/`RECONCILE_TOKEN`)均为必填**(fail-closed,`SAF-AUTH-*`):缺失即 `Config::from_env()` 报错启动失败,无"未配置则放行"降级。示例占位见仓库根目录 `.env.example`(仅占位符,禁放真实密钥)。
> **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。(状态:D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**。)
### 7.2 密钥管理
- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置走环境变量,`ARCH-CONFIG-ENV`)。
- 环境变量由 `config` 层直接读取(`std::env::var`,`ARCH-CONFIG-ENV`);缺失必填项即启动失败(**无** `${VAR}` 插值/figment)。
- 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。
- 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。
- 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
### 7.3 访问控制
- **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**——
- `/reconcile`:请求头 `Authorization: Bearer <RECONCILE_TOKEN>`;
- `/webhook/tg`:请求头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`;
- `/push/jmap`:请求体 `verificationCode == JMAP_PUSH_VERIFICATION`。
比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。三密钥必填,缺失即启动失败(§7.1)。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready`(`ARCH-READY-PLACEHOLDER`)为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。
- `/healthz` = liveness(进程存活),语义长期稳定。
- `/ready` **当前阶段为占位**:固定返回 `200`,**不执行** Redis PING / JMAP session / Telegram getMe 等依赖探测(`ARCH-READY-PLACEHOLDER`)。真实依赖探测属后续门禁 `GATE-READY-DEPS`(`redis` 接入且 JMAP session 可用后实现,见 §10.0)。
- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;**强制拒绝逻辑随阶段2 渠道接入落地**。
- **命令最小化**:仅暴露必要命令;`/send` 需二次确认。
- **速率**:teloxide `throttle` + `notify` 自有令牌桶(推送不能挤占用户命令回复)。
### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
```json
{
"jmap_timeout_ms": 15000,
"telegram_timeout_ms": 10000,
"llm_timeout_ms": 30000,
"max_retries": 3
}
```
- 三个 timeout 单位均为毫秒,范围 `100..=300000`;`max_retries` 范围 `0..=5`,表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值 `15000 / 10000 / 30000 / 3`;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。
- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;成功返回 `204 No Content`,不会返回配置或密钥。Wire 字段为 `bot_token: string`、`telegram_chat_id: i64`、`chat_allowlist: i64[]`、`telegram_webhook_secret: string`、`jmap_session_url: string`、`jmap_username: string`、`jmap_password: string`、`jmap_push_verification: string`、`account_id: string | null`、`llm_enabled: boolean`、`llm_allow_net: boolean`、`llm_api_key: string | null`、`llm_base_url: string | null`、`llm_model: string | null`、`reconcile_token: string`、`worker_token: string`。该 API 故意没有 GET;SPA 不回填业务配置,每次完整替换需重输必填密钥。
- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。
### 7.4 TLS(代码/依赖行为)
- TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。
- 证书校验**默认强制**,仅测试可关。
- 镜像内系统 CA 信任库、内部 CA 注入、入站 webhook TLS 终止等运维细节见 deployment.md。
### 7.5 生产红线:无数据库 / 无本地写入 / 标准输出日志(deployment.md §0/§8.2)
本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态:
| 红线 ID | 含义 | 违反示例(禁止) |
|---|---|---|
| `C-NO-DB` | 生产不使用任何数据库;Redis 是唯一生产状态存储 | 加 SQLite 去重表、加 Postgres 会话 |
| `C-NO-LOCAL-WRITE` | 禁止本地文件/目录写入;不挂本地卷 | 写 `./logs/app.log`、写 `./tmp/…`、写本地 `sinceState.json` |
| `C-LOG-STDOUT-ONLY` | 日志只写 stdout/stderr;平台负责采集 | 引入 `tracing-appender`、`rolling-file`、`FileAppender` |
| `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 |
| `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex<HashSet>` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 |
**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。
---
## 8. 错误处理与可观测性
### 8.1 统一错误枚举
```rust
#[derive(thiserror::Error, Debug)]
pub enum BotError {
#[error("jmap: {0}")]
Jmap(#[from] jmap_client::Error),
#[error("telegram: {0}")]
Telegram(#[from] teloxide::errors::RequestError),
#[error("config: {0}")]
Config(String),
#[error("storage: {0}")]
Storage(#[from] redis::RedisError),
#[error("rate limited, retry later")]
RateLimited,
#[error("unauthorized chat {0}")]
Unauthorized(i64),
#[error("llm: {0}")]
Llm(LlmErr), // 见 §12.7
}
```
### 8.2 错误 → 用户消息 映射
| 底层错误 | Bot 行为 |
|---|---|
| `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" |
| `Forbidden` / `Unauthorized chat` | 静默拒绝;日志告警 |
| `Method(InvalidArguments)` | 回复"参数有误 + 正确用法" |
| `Set(creation/update/destroy)` | 回复具体记录级失败原因 |
| `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) |
| `Llm(*)` | 见 §12.7(熔断/回退/徽标) |
### 8.3 可观测性
- `tracing`(teloxide feature 已开启)+ `tracing-subscriber`(fmt + EnvFilter)。
- 关键 span:`jmap.request`、`notify.push_received`、`notify.reconcile`、`telegram.command`、`llm.call`(记 prompt 哈希/response 长度/latency/status,**不记 prompt 明文**)。
- 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。
- 优雅退出:信号处理 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(不涉及本地文件)。
- **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 deployment.md §6.4/§6.5(`NFR-NOTIFY-SLA`)。
---
## 9. 测试策略
### 9.1 层次
| 层 | 工具 | 内容 |
|---|---|---|
| 单元 | `cargo test` | `config` 解析、`util::retry` 退避、`formatting` HTML escape |
| 契约 | mock | `jmap::JmapService` 用 mock Push 回调 + changes 响应 + 预置 JMAP JSON 验证领域动词;`llm` 用 mock OpenAI 端点 |
| 集成 | teloxide mock Bot + mock JMAP | 命令端到端:发 `/list` → 验证回复来自 mock 数据 |
| 真实(手动) | Stalwart 实例 | 标 `#[ignore]`,`cargo test -- --ignored` 按需跑 |
### 9.2 mock 策略
- `JmapService` 持有 trait `JmapBackend`(`list_emails/read_email/send_email/changes…`),生产实现包装 `jmap_client::Client`,测试用 `MockBackend`(基于 `Vec<EmailSummary>`)。
- Push 回调 payload 用 `futures::stream::iter` 造假 `StateChange`,验证 worker 的 `Email/changes` 增量逻辑与去重(`mod_dedup` + `mod_streams`)。
- teloxide 提供 `Requester` mock,验证 `send_message` 被以期望内容调用(仅 `channel::telegram` 测试使用)。
- 渠道契约:领域流程用 `MockChannel`(实现 `Channel/Notifier` trait)驱动,验证领域输出与渠道渲染解耦、领域层无 teloxide 依赖。
- `llm::client` 注入 mock OpenAI `/chat/completions` 端点。
### 9.3 关键不变量测试
- `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。
- 通知去重:同一 `email_id` 不重复推送。
- 正文转义:含 `<script>` 的邮件正文在 HTML 模式下被转义。
- **安全边界断言(硬性,见 AGENTS.md)**:
- VIEW/查看原文路径:mock AI 端点零请求(LLM client 未被调用);
- 新邮件通知:消息内无正文内容(断言泄漏);
- 长邮件:正文 > 4000 字符不发送全文;
- AI 调用前置:未确认前 LLM 零调用;
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
- AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 返回 `200` 且**响应体不含敏感信息**;`/ready` 在阶段0 断言为**占位**(不做依赖探测,测试**不得**断言 Redis/JMAP/TG 检查已发生)。
- **渠道解耦**:领域模块(jmap/llm/intent/notify::core)编译不依赖 teloxide(crate 分层 + clippy/评审约束);`channel::telegram` 是唯一导入 teloxide 的模块。
- **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝。
- **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
---
## 10. 分阶段实施计划
> 前提:先 `rustup` 装工具链(stable)。CI 与发布流见 deployment.md。
### 10.0 阶段 0:脚手架与 HTTPS 入口骨架(已完成,待过 P0 门禁)
> **阶段0 实际交付**(`ARCH-STAGE0`):`cargo` 工程 + 模块骨架(`main` / `config` / `error` / `domain` / `channel` / `notify`),单端口 axum 入口与 5 条路由占位(`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`),env 配置骨架、最小测试。其中三条写路径的入口鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`);**`/healthz` 为 liveness;`/ready` 为占位 200(`ARCH-READY-PLACEHOLDER`),不做依赖探测**(真实探测见后续门禁 `GATE-READY-DEPS`)。
- **实际依赖**(Cargo.toml 现状,`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1`):`axum 0.8`(单一 HTTPS 入口)、`async-trait`、`secrecy`、`subtle`(常数时间鉴权比较)、`serde`、`serde_json`、`thiserror`、`tokio`、`tracing`、`tracing-subscriber`、`url`(`JMAP_SESSION_URL` 归一化解析)、`jmap-client =0.4.2`(`default-features = false, features = ["async","rustls"]`);dev-dependencies:`tower 0.5`(路由测试)。
- **尚未引入**(计划见后续阶段,文档不得声称已用):`teloxide`(阶段2)、`redis`(阶段3/4)、`reqwest`(阶段3.5)。**未使用 figment**:配置为手工 `std::env` 解析(`ARCH-CONFIG-ENV`,§7.1)。
- **阶段1 依赖现况**(`ARCH-DEPS-STAGE1`):`jmap-client` **已引入**(当前 `=0.4.2`,`default-features = false, features = ["async","rustls"]`,版本与 features **以 `Cargo.toml` 为准**)。⚠️ 其默认 features `["async","websockets","aws_lc_rs"]` **含 WebSocket 栈**,故必须关闭默认 features 且不选 `websockets`;JMAP 仅走 **HTTPS 短请求**(Core/Mail),以遵守 `C-NO-LONG-CONN`(无 WS/SSE/长轮询)。
- `ACCOUNT_ID` **可选**(`REQ-SINGLE-ACCOUNT`):**留空 → 取 JMAP session 的默认/主账户**;显式值经校验后使用;多账户 = 多个 bot 实例。
- axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。
- 阶段0 **不实现** SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);**入口鉴权(`R1`/`SAF-AUTH-*`)已作为 fail-closed 硬门禁落地**(见 §7.3、§10.1 注),鉴权之后的业务体仍为占位。
**阶段0 P0 门禁(`GATE-P0`)——进入阶段1前必须全部通过:**
1. `cargo fmt --check` 通过(无格式差异)。
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;占位符用**局部** `#[expect(dead_code, reason="稳定ID+阶段0占位")]`,禁 crate 级 `allow`)。
3. `cargo test` 通过(含路由/配置最小测试)。
4. 上述三条**在 Debian `rust:1-slim-bookworm` 容器内**执行通过(`C-DEBIAN-SLIM`)。
5. 配置读取为 env-only(无 figment/TOML);`SecretString` 包裹秘密且 `Debug` 不泄密;`JMAP_PASSWORD` 安全访问器保留。
6. `RUN_MODE` 被实际消费(`webhook`/`reconcile` 至少显式分派,保持无副作用)。
7. `.gitignore` 存在(排除 `target/` 等;**不**擅自初始化 git)。
8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。
**后续门禁(`GATE-READY-DEPS`)——`redis` 接入且 JMAP 只读 adapter 可用后必须实现:**
- `/ready` 由**占位 200**(`ARCH-READY-PLACEHOLDER`)升级为**真实依赖探测**:Redis PING + JMAP session(Basic)连通性(+ 视需要 Telegram getMe);
- 依赖不可用时返回 `503`,用于编排器的 readiness/流量摘除;
- `/healthz` 保持纯 liveness;两者继续满足 `SAF-PROBE-PUBLIC`(公开、无敏感信息)。
- **在 `GATE-READY-DEPS` 落地前,文档/测试/编排探针描述不得声称 `/ready` 已检查依赖。**
**生产红线(贯穿所有阶段)**:
- **无数据库**(`C-NO-DB`):Redis 是唯一生产状态存储;不引入 SQLite/Postgres/MySQL/嵌入式数据库。
- **无本地文件写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):平台负责采集;禁用文件日志后端。
- **日志/Redis 内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;禁止密钥、JMAP 邮件正文、AI 请求/响应、附件内容。
- **无进程内恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅性能优化,丢失安全可重入。
**阶段1 推进边界(`BOUND-STAGE1`):**
- 仅在 `GATE-P0` 全项通过后进入阶段1(JMAP 只读)。
- **R1(入口真实鉴权)已在阶段0 后置修复中落地**(`SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`):`/reconcile` 校验 `Authorization: Bearer RECONCILE_TOKEN`、`/webhook/tg` 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`、`/push/jmap` 校验 `verificationCode == JMAP_PUSH_VERIFICATION`;常数时间比较,失败 `401` 且无副作用,三密钥必填(缺失即启动失败)。阶段1 不再重复实现 R1。
- **仍需注意**:`GATE-P0` 未全项通过前**不应**将容器暴露到公网(鉴权已 fail-closed,但依赖/占位与 CI 门禁尚未验收)。
- 阶段1 只做 JMAP 只读(`list_folders`/`list_emails`/`read_email`),不引入发送/推送/AI。
### 10.1 阶段 1:JMAP 只读(1.5d)
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。
- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 env 骨架(**非** `bot.example.toml`,见 `ARCH-CONFIG-ENV`)。
- `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行前不得声称真机通过**):已实现 = 包装 `Client::connect`(Basic 认证 `C-AUTH-APP-BASIC`)、**URL 归一化**(`JMAP_SESSION_URL` 接受服务基地址或完整 `…/.well-known/jmap`,归一化为 origin/base 后传入,避免重复路径;仅 HTTPS、禁内嵌凭据与 query,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、**account 选择**:`ACCOUNT_ID` 留空取 session 主账户、显式值校验后使用(`REQ-SINGLE-ACCOUNT`)。
- `domain::jmap::JmapService::list_folders / list_emails / read_email`(query/get;`list_emails` 含 `limit` 边界;`received_at` 解析;`read_email` 多 part 拼接见 `REQ-JMAP-RAW-MULTIPART`)。
- **R1 入口鉴权已就绪**:`/reconcile`/`/webhook/tg`/`/push/jmap` 三入口鉴权(`SAF-AUTH-*`)已在阶段0 落地并通过单测;阶段1 仅需在其后接入真实业务逻辑(不要在阶段1 放宽/绕过鉴权)。
- `#[ignore]` 真实测试(`GATE-G1-JMAP-READONLY`):经**环境变量**驱动,运行命令 `cargo test -- --ignored jmap::`(`--ignored` 是 libtest 参数,必须置于 `--` 之后);仅编译不执行用 `cargo test --no-run`(编译含 `#[ignore]` 的测试目标)。**缺环境时清晰跳过且不泄露任何密钥**;CI 默认不跑真机用例,mock 测试继续保留。**真机用例通过与否须以实际 `--ignored` 运行为准——不得在未运行的情况下声称"真机通过"。**
- 验收:`cargo test`(mock)全绿;`cargo test -- --ignored jmap::`(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。
### 10.2 阶段 2:渠道适配骨架(2d)
- `channel/` 定义 Channel / Notifier / MessageAdapter 抽象 + 领域 Command/Notification 类型。
- `channel::telegram` 装配 Dispatcher + chat 白名单中间件 + throttle(teloxide 仅存在于该模块)。
- 命令:`/start /folders /list /read`(只读闭环)。
- `render.rs`:领域 Notification → TG Markdown/HTML 渲染 + 转义(adapter 内部)。
- 会话存储(外部 Redis,`C-REDIS-ONLY-STATE`;**不用 SQLite**)记当前 folder/page。
- 验收:本地 **webhook 形态**(`webhooks-axum` 或纯 axum),测试客户端发命令能读邮件;领域模块不 import teloxide。
### 10.3 阶段 3:发送邮件 + 状态管理(1.5d)
- `domain::jmap::JmapService::send_email`(draft + submission_set)。
- `/send` 用 teloxide 对话 FSM 多步收集 to/subject/body。
- `/flag /unseen`(keywords)。
- 验收:手机发邮件、标记已读。
### 10.4 阶段 3.5:LLM 门面 + 回退(1.5d)
见 §12.9。
### 10.5 阶段 4:实时推送(Push 回调 + Streams worker + 外部 Cron 对账)(2d)
- `notify::push_handler`:注册 `push_subscription` + verificationCode 握手,`POST /push/jmap` 校验→去重→入 Redis Streams→2xx。
- `notify::worker`:Redis Streams 消费(`mod_streams`)→ `Email/changes` 增量 → 通知 → 推进 sinceState → XACK。
- `notify::reconcile`:`/reconcile` + `reconcile` 子命令,外部 HTTPS Cron 触发(`FLOW-RECONCILE`);兼做 Redis 丢失后的游标重建。
- `sinceState` 写外部 Redis(`mod_sincestate`,`C-REDIS-ONLY-STATE`;不用 SQLite/文件 `NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`)。
- 验收:向 Stalwart 发测试邮件,经 Push 回调 + worker,Telegram 在 ~秒级收到推送;重启不重发;清空 Redis 后由对账补发。
### 10.6 阶段 5:搜索 + 搜索片段 + 打磨(1.5d)
- `/search` + `SearchSnippet/get` 高亮。
- 错误消息打磨、速率/退避实测、`clippy`/`fmt`、README。
### 10.7 总估时
~10.5 人日(不含等待用户确认与真实联调排障)。
---
## 11. 待确认问题(产品/架构类)
> ⚠️ 部署/平台类决策已确认(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账),仅余 `Q-DEP-A`/`Q-DEP-B` 运维待办,见 deployment.md §9。
### 11.1 认证方式(已确认)
- **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。
- 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。
### 11.2 实时通道(已定,见 deployment.md)
- 唯一通道 = **JMAP Push HTTPS 回调**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。
- **Q2**:Stalwart 实例是否启用 JMAP `PushSubscription` capability(`urn:ietf:params:jmap:push`)?需确认,决定 Push 回调可用性。
- **Q3**:Stalwart 对 Push 回调的重试策略/次数?决定去重 TTL 与对账间隔取值(见 deployment.md)。
### 11.3 部署形态(已确认,架构相关)
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。
- 部署形态 = **webhook-only + 通用 HTTPS-only Docker + 外部 Cron 对账**(deployment.md `C-NO-LONG-CONN`/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`)。
- **多实例 LB/HA(已确认,`ARCH-LB-WORKER`)**:可选在多个 serverless 平台部署同镜像、共享同一 Redis,前置免费 Cloudflare Worker 做唯一入口与故障转移;Worker 代码位于子目录 [`cloudflare-worker/`](../cloudflare-worker/)(非本 Rust 二进制),部署见 `docs/deployment.md` §10.8。信任模型为**透传**(后端仍 fail-closed 校验,`SAF-LB-PASSTHRU`),后端间**共享同一组 secret**(`C-LB-SHARED-SECRETS`),`/reconcile` **Redis 锁**单实例(`SAF-RECONCILE-LOCK`),Worker 提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。双活或主备均可。详见 `docs/deployment.md` §10。
### 11.4 邮件正文呈现
- **Q6**:Telegram 消息用 HTML 还是 MarkdownV2?HTML 转义更可控(推荐 HTML),但 MarkdownV2 视觉更好。
- **Q7**:长邮件策略——已确认"禁止直接发送完整原文,超过阈值(默认 4000)发预览 + `[AI 总结][继续查看原文]` 选项",AI 摘要目标 ~300 字、代码侧不做字符级硬截断(见 §12.3/§12.2)。阈值默认值是否合适见 Q29。
- **Q8**:附件是否在 Telegram 直接下载发送?(Telegram 限 50MB 文件;大附件建议只给下载链接 via `download_url`)。
### 11.5 推送范围与去重
- **Q9**:推送所有新邮件,还是仅特定文件夹/发件人/关键词?(Stalwart 端可用 Sieve 过滤,Bot 端也可二次过滤。)
- **Q10**:是否需要"摘要聚合"(如每早 8 点汇总未读)?由**外部 HTTPS Cron 调度器**触发 `/reconcile` 或新增汇总路由实现(**不在容器内自持定时器**,`C-NO-LONG-CONN`;+0.5d)。
### 11.6 Stalwart 版本/能力
- **Q11**:Stalwart 版本是否启用 Sieve(draft-12)能力?若启用,部分"自动归档/标记"可下沉到服务端 Sieve,Bot 只做通知,减少往返。
- **Q12**:是否需要 `Identity` 多发件身份切换?(若只有一个身份可忽略。)
### 11.7 工程取向
- **Q13**:是否要求 `#![forbid(unsafe_code)]`(与 jmap-client 一致)?建议是。
- **Q15**:日志/指标是否需要对接外部(Loki/Prometheus)?默认只本地 stdout。
- (CI 平台为通用容器 CI,`NG-SERVERLESS-BIND`;具体见 deployment.md。)
---
## 12. AI 辅助能力:架构、确认门槛、失败回退
> **用户已确认的 7 项需求(本节据此设计,后续所有表述以此为准)**:
> 1. **AI 仅在被明确要求并确认后接触正文**(`REQ-AI-CONFIRM` / `REQ-AI-EXTERNAL-CONSENT`):只有当用户明确发起"分析/总结/翻译"意图**并再次确认**后,才允许把正文喂给 LLM;**仅当用户明确允许时才向外部 AI 发送邮件正文**(默认不外发);其余场景(包括查看原文)**一律不经 AI**,由 JMAP 直取。
> 2. **查看原文始终 JMAP 直取**(`REQ-VIEW-DIRECT`):`/read` 与任何"看正文"动作走 `JmapService::read_email`,不走 LLM。
> 3. **长邮件禁止直接发送完整原文**(`REQ-LONG-EMAIL`):正文超过阈值(默认 4000 字符,见 §12.3,可调)时,Bot **不直接发送全文**,而是提示"正文较长,请电脑查看"或"选择 AI 总结";AI 摘要目标约 **300 字**,**不做代码侧硬性数学截断**(由 LLM 自然生成 ~300 字)。
> 4. **分析/摘要结果不持久化**(`REQ-ANALYSIS-EPHEMERAL`):不写缓存、不入 Redis、不留磁盘;每次请求即取即弃(无状态天然友好)。
> 5. **附件按需拉取(保留)**(`REQ-ATTACH-ONDEMAND`):不预载;`Blob/get` 仅在用户点击下载时触发,单文件 ≤50MB 发文件,超限给 `download_url` 链接。
> 6. **AI 失败约 3 次后需用户确认的非 AI 回退(保留)**(`REQ-AI-FUSE`):连续 3 次失败 → 熔断 + 向用户弹确认;选择"关闭 60s" 冷却期内直接走规则模板 + 原文直取路径;选择"恢复 AI" 则做一次探测。
> 7. **OpenAI-compatible 环境变量(保留)**(`REQ-LLM-OPENAI-COMPAT`):`LLM_API_KEY / LLM_BASE_URL / LLM_MODEL` 等。
>
> 本节仅做设计,不写业务代码。
### 12.1 模块划分(增量,接 §6)
```
src/domain/llm/ # 零渠道依赖(领域层,见 §5.2)
├── mod.rs # LlmService(门面:summarize / intent / draft;仅确认后调用)
├── client.rs # 包装 axum + reqwest::Client,OpenAI /chat/completions
├── config.rs # 从环境装载 + 校验
├── policy.rs # 熔断/退避/限次(对齐需求 6)
├── fallback.rs # 非 AI 回退:规则模板 + 原文直取路径(对齐需求 1/2)
└── audit.rs # tracing span:prompt hash / token / latency / status
```
### 12.2 OpenAI-compatible 环境变量(需求 7)
```bash
export LLM_API_KEY=...
export LLM_BASE_URL=https://api.openai.com/v1 # 可指向任何 OpenAI-compatible 端点
export LLM_MODEL=... # 如 gpt-4o-mini
# 可选
export LLM_TEMPERATURE=0.2
export LLM_MAX_TOKENS=1024
export LLM_TIMEOUT_SECS=30
export LLM_MAX_RETRIES=3 # 对应需求 6 的"约 3 次"
export LLM_ENABLED=true|false # 一键降级开关
export LLM_ALLOW_NET=true|false # 默认 false;开启才允许 LLM 出网
# 摘要目标长度(写入 prompt 的系统指令,由 LLM 自然控制;代码侧不做字符截断)
export LLM_SUMMARY_TARGET_CHARS=300
```
- 客户端:**axum**(或裸 `reqwest` + `serde`),`POST {LLM_BASE_URL}/chat/completions`,`Authorization: Bearer $LLM_API_KEY`。
- **仅在用户明确要求分析/总结/翻译并确认后**才调用 LLM;此时把**最小必需上下文**(Subject + 正文 + 用户指令)交给 LLM,prompt 系统指令写明"请生成约 300 字中文摘要"。
- 摘要结果**不持久化**(需求 4):不写 Redis、不落盘,处理完即弃;无状态天然友好。
- 默认关闭网出能力(`LLM_ALLOW_NET=false`)降低数据外泄风险;密钥不入仓库;`config::Config` 启动时强校验,缺失且 `LLM_ENABLED=true` → 启动失败或降级为禁用并告警。
- 运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
### 12.3 正文获取策略与长邮件处理(需求 1/2/3/5)
- **查看原文 = JMAP 直取**:`/read` 与任何"看正文"动作走 `JmapService::read_email`,**不经 LLM**;LLM 不在查看路径上。
- **AI 接触正文的前置门槛**:只有当用户发起明确的"分析/总结/翻译"意图**并再次确认**(例如点 `[AI 总结]` 按钮 / 执行 `/summarize`)后,才把正文作为 LLM 输入。未经确认 → LLM 看不到正文。
- **长邮件禁止直接发送完整原文**(需求 3):
- 当正文长度 > 阈值(默认 **4000 字符**,由 §12.2 配置,可调),Bot **不发送全文**,而是回复一段简短预览(如 Preview 120 字符 + 发件人/主题/附件概览)并给出选项:
> "邮件正文较长(约 N 字)。在手机上不便完整阅读,建议在电脑端查看。如需在此查看:`[AI 总结]`(约 300 字)`[继续查看原文]`(截断)"
- "AI 总结"路径:经用户确认 → 把正文交 LLM → 生成约 300 字摘要(目标由 prompt 控制,**代码侧不做字符级硬截断**)。
- "继续查看原文"路径:仍 JMAP 直取,发送截断版(明确标注"已截断,完整请电脑查看"),**绝不直接发送完整原文**。
- **附件按需**(需求 5 保留):Telegram 端先给"📎 3 个附件(PDF, 2.1MB)"概览 + 下载按钮;用户点击才触发 `Blob/get` 流式下载 → `Telegram::send_document`,单文件 ≤50MB,超限只给 `download_url` 链接。避免启动期预载全部 blob,减少 JMAP 出站与内存峰值。
- AI 摘要**永远不**把附件内容喂入 prompt(除非后续单独功能开启且白名单限定),保持隐私默认。
### 12.3.1 AI 授权期限与 Redis TTL(`REQ-AI-CONSENT`)
- AI 授权必须由用户明确选择期限;可选临时一次(实现为一次授权窗口,默认 1 小时)、1 小时、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。
- Redis 的 consent key 仅保存 chat id 对应的到期 Unix 时间,并使用**与所选期限完全相同**的 `SET EX` 秒数;不使用隐式 30 天默认值,也不保存正文或摘要。
- 到期由 Redis TTL 和读取时的到期校验共同保证,摘要请求回到元数据模式并提示“授权已到期”;用户重新选择期限后才可再次授权。`/ai off` 立即删除 key。
### 12.4 失败检测与熔断(需求 6)
```
LlmService.call(kind: LlmKind, input, ctx)
├─ 调用 axum → Result<Response, LlmErr>
│ 成功 → 正常路径
│ 失败(4xx/5xx/超时/网络/401) → 记入 policy
└─ policy.tick() 计数:
失败次数 >= 3(LLM_MAX_RETRIES) 且 在 TTL 窗口内
→ 触发"熔断 + 需确认回退"(进入半开状态)
```
- 触发后向用户发一条**显式确认消息**(`/llm-fallback` 风格的 inline-button):
> "AI 最近 3 次调用失败,我切换到规则模板回复。是否继续? `[恢复AI(重试)]` `[关闭AI 60s]`"
- 用户选择后:
- "恢复 AI" → 走一次探测请求,成功则重置计数、退出熔断;失败则维持熔断;
- "关闭 60s" → 进入冷却期,期间所有 LLM 调用直接走 fallback 路径。
- 熔断状态**持久化**到 Redis(TTL = 冷却期),无状态化:实例重启/迁移后能从 Redis 恢复;Redis 丢失时退化到进程内短窗口(接受 60s 内的漏判)。Redis 运维与存活窗口见 deployment.md。
### 12.5 非 AI 回退(需求 6,与"原文直取"一致)
- `fallback.rs` 提供与 AI 接口相同的签名(`fn summarize(input:&Input) -> Output`),内部走**规则 + 原文直取**:
- 摘要回退:截取 `Subject + Preview(120 chars)`,不解析 HTML;
- 意图识别回退:用关键字/正则匹配(如"归档"、"删除"、"回复")走确定性分支;
- 草稿/格式化回退:纯模板字符串替换。
- 所有回退响应均**显式标注**"AI 不可用"徽标,避免用户误以为是 AI 生成。
- 回退路径**不经过 LLM 客户端**,保证零 LLM 依赖;且**不触发 3 次熔断逻辑**(熔断只统计 LLM 调用)。
### 12.6 无状态/容器化适配
- **分析/摘要结果不持久化**(需求 4):不写 Redis、不落盘、不做结果缓存;每次请求即取即弃。**删除"摘要结果入 Redis 缓存"方案**——多实例/冷启动只影响重复调用成本,不缓存保证数据最少留存。(与 `C-REDIS-ONLY-STATE` / `SAF-LOG-PURITY` 一致:Redis 上 LLM 相关状态仅熔断计数;**不写** prompt/completion 明文。)
- **熔断计数存 Redis**(`INCR + EXPIRE`),跨实例共享(这是 Redis 上唯一与 LLM 相关的状态)。
- `LLM_SUMMARY_TARGET_CHARS`、`LLM_MAX_RETRIES`、熔断冷却时间通过 config 下发,不写死。
### 12.7 错误处理与可观测性(接 §8)
新增 `BotError::Llm(LlmErr)`;`LlmErr` 细分 `Unauthorized`(401/403)、`RateLimited`(429)、`Timeout`、`BadResponse`、`Transport`。用户消息映射:
- 熔断中 + 未确认 → 静默走回退,附"AI 不可用"徽标;
- 恢复失败 → 再次提示用户确认;
- 审计:`tracing` span `llm.call` 记录 prompt 哈希、response 长度、latency、status,不记录 prompt 明文(合规)。
### 12.8 新增待确认问题
- **Q25**:LLM 提供方是 OpenAI 官方还是自托管/Ollama/vLLM?`LLM_BASE_URL` 是否需要 TLS 互信?出网白名单是否允许?
- **Q26**:`LLM_ALLOW_NET` 默认 false 是否可接受?(即 AI 调用本身也视为敏感出网操作,需用户显式开关。)
- **Q27**:熔断冷却期(60s 还是 5min)与"约 3 次"阈值需确认;"约 3 次"是否含 429 限流?
- **Q28**:附件"按需"指**下载并作为 Telegram 文件回传**,还是**仅给 Stalwart `download_url` 链接**?后者更省流量与成本,前者体验更顺。
- **Q29**:长邮件"禁止直接发送完整原文"的阈值(默认 4000 字符)是否合适?"AI 总结"目标 ~300 字是否满足预期(过短/过长可调整,由 prompt 自然控制)?
- **Q30**:`LLM_ENABLED=false` 时是否**启动即禁用 AI 且不写熔断**(最简降级),还是仍保留探测?推荐前者。
### 12.9 实施计划增量(接 §10)
在阶段 3 与 4 之间插入 **阶段 3.5:LLM 门面 + 回退(1.5d)**
- `llm::client` + `axum`/reqwest 调用;
- `llm::policy` 3 次熔断 + Redis 共享计数(熔断是 Redis 上唯一的 LLM 相关状态;**摘要结果不持久化**);
- `llm::fallback` 规则模板 + 原文直取;
- 长邮件策略:正文 > 4000 字符 → 不发送完整原文,改发预览 + `[AI 总结][继续查看原文]` 选项(对齐 §12.3);
- `telegram/formatting` 增加"AI 不可用"徽标;
- 验收:
- `LLM_ENABLED=true` 且用户明确确认分析 → 正常 ~300 字摘要(不做字符级硬截断);
- 长邮件默认**不发送完整原文**(仅预览 + 选项);
- 模拟 3 次 500 → 弹确认;选"关闭 60s" → 60s 内全走回退并带徽标;
- `LLM_ENABLED=false` → 直接全走回退;
- 查看原文(`/read`)路径**绝不**触发 LLM。
---
## 附:调研依据(可复核)
- `stalwartlabs/jmap-client` main 分支:`src/lib.rs`(URI/Method/DataType/Error)、`src/client.rs`(认证/连接/event_source)、`src/email/`、`src/email_submission/helpers.rs`、`src/event_source/`、`src/push_subscription/`(create/verify/update_types/destroy)、`src/core/error.rs`、`Cargo.toml`、`README.md`、`examples/`。
- crates.io:`teloxide` 0.17(features 列表)、`jmap-client` 元数据。
- `stalwartlabs/mail-server` main 分支:`crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`crates/jmap/src/push/`、`api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。
- 项目目录 `/home/okabe/Repo/stalwart-bot/`:greenfield(首次构建需 `rustup`)。
- 部署/平台相关调研依据(lambda_runtime/worker/aws-sdk 等)见 deployment.md。

178
src/ai.rs Normal file
View File

@ -0,0 +1,178 @@
//! HTTPS OpenAI-compatible summary client. It never persists prompts or results.
use crate::state::{runtime_provider, OutboundConfig, RuntimeConfigProvider};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub enum AiError {
InvalidEndpoint,
Request,
Response,
}
pub struct LlmClient {
http: reqwest::Client,
endpoint: String,
key: SecretString,
model: String,
max_chars: usize,
runtime: RuntimeConfigProvider,
}
impl LlmClient {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stable client constructor retained for adapter tests"
)
)]
pub fn new(
endpoint: String,
key: SecretString,
model: String,
max_retries: u8,
max_chars: usize,
) -> Result<Self, AiError> {
Self::new_with_timeout(endpoint, key, model, max_retries, max_chars, 30_000)
}
pub fn new_with_timeout(
endpoint: String,
key: SecretString,
model: String,
max_retries: u8,
max_chars: usize,
timeout_ms: u64,
) -> Result<Self, AiError> {
let url = url::Url::parse(&endpoint).map_err(|_| AiError::InvalidEndpoint)?;
if url.scheme() != "https" {
return Err(AiError::InvalidEndpoint);
}
Ok(Self {
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(timeout_ms.max(100)))
.build()
.map_err(|_| AiError::Request)?,
endpoint: endpoint.trim_end_matches('/').into(),
key,
model,
max_chars: max_chars.max(1),
runtime: runtime_provider(OutboundConfig {
llm_timeout_ms: timeout_ms.max(100),
max_retries: max_retries.min(5),
..OutboundConfig::default()
}),
})
}
pub fn with_runtime(
endpoint: String,
key: SecretString,
model: String,
max_chars: usize,
runtime: RuntimeConfigProvider,
) -> Result<Self, AiError> {
let url = url::Url::parse(&endpoint).map_err(|_| AiError::InvalidEndpoint)?;
if url.scheme() != "https" {
return Err(AiError::InvalidEndpoint);
}
Ok(Self {
http: reqwest::Client::new(),
endpoint: endpoint.trim_end_matches('/').into(),
key,
model,
max_chars: max_chars.max(1),
runtime,
})
}
pub async fn summarize(&self, body: &str) -> Result<String, AiError> {
let request = ChatRequest {
model: &self.model,
messages: vec![ChatMessage {
role: "user",
content: body,
}],
};
let (timeout_ms, max_retries) = self
.runtime
.read()
.map(|config| (config.llm_timeout_ms, config.max_retries.min(5)))
.unwrap_or((30_000, 3));
for _ in 0..=max_retries {
let response = match tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms.max(100)),
self.http
.post(format!("{}/chat/completions", self.endpoint))
.bearer_auth(self.key.expose_secret())
.json(&request)
.send(),
)
.await
{
Ok(Ok(response)) => response,
_ => continue,
};
let status = response.status();
if status.is_success() {
let value = response
.json::<ChatResponse>()
.await
.map_err(|_| AiError::Response)?;
let text = value
.choices
.into_iter()
.next()
.map(|choice| choice.message.content)
.ok_or(AiError::Response)?;
return Ok(text.chars().take(self.max_chars).collect());
}
if !(status.as_u16() == 429 || status.is_server_error()) {
return Err(AiError::Request);
}
}
Err(AiError::Response)
}
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<ChatMessage<'a>>,
}
#[derive(Serialize)]
struct ChatMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Deserialize)]
struct Choice {
message: ChatMessageOwned,
}
#[derive(Deserialize)]
struct ChatMessageOwned {
content: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn llm_requires_https() {
assert!(matches!(
LlmClient::new(
"http://localhost".into(),
SecretString::new("x".into()),
"m".into(),
1,
300,
),
Err(AiError::InvalidEndpoint)
));
}
}

200
src/channel.rs Normal file
View File

@ -0,0 +1,200 @@
use async_trait::async_trait;
use crate::domain::{Notification, UserCommand};
#[expect(dead_code, reason = "稳定ID+阶段0占位:渠道抽象供后续 adapter 使用")]
#[async_trait]
pub trait Channel: Send + Sync {
async fn receive(&self, command: UserCommand);
}
#[expect(dead_code, reason = "稳定ID+阶段0占位:通知抽象供后续 adapter 使用")]
#[async_trait]
pub trait Notifier: Send + Sync {
async fn notify(&self, notification: Notification);
}
#[expect(dead_code, reason = "稳定ID+阶段0占位:渠道消息适配抽象")]
pub trait MessageAdapter: Send + Sync {
fn render_notification(&self, notification: &Notification) -> String;
}
/// Telegram is intentionally only an adapter boundary in stage 0 (no bot commands yet).
pub mod telegram {
use crate::domain::Notification;
use crate::state::RuntimeConfigProvider;
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
pub struct TelegramClient {
http: reqwest::Client,
token: SecretString,
endpoint: String,
runtime: RuntimeConfigProvider,
}
#[derive(Serialize)]
struct SendMessage<'a> {
chat_id: i64,
text: &'a str,
}
impl TelegramClient {
pub fn with_runtime(token: SecretString, runtime: RuntimeConfigProvider) -> Self {
Self::with_endpoint_inner(token, runtime, "https://api.telegram.org".into())
}
fn with_endpoint_inner(
token: SecretString,
runtime: RuntimeConfigProvider,
endpoint: String,
) -> Self {
Self {
http: reqwest::Client::new(),
token,
endpoint,
runtime,
}
}
/// Test-only endpoint injection; production always uses Telegram's official HTTPS host.
#[cfg(test)]
pub fn with_endpoint(
token: SecretString,
runtime: RuntimeConfigProvider,
endpoint: String,
) -> Self {
Self::with_endpoint_inner(token, runtime, endpoint)
}
/// Sends metadata only; body text is intentionally not accepted here.
pub async fn send_notification(
&self,
chat_id: i64,
notification: &Notification,
) -> Result<(), String> {
let text = format!(
"From: {}\nSubject: {}\nReceived: {}",
notification.sender, notification.subject, notification.received_at
);
self.send_text(chat_id, &text).await
}
pub async fn send_text(&self, chat_id: i64, text: &str) -> Result<(), String> {
let (timeout_ms, max_retries) = self
.runtime
.read()
.map(|config| (config.telegram_timeout_ms, config.max_retries.min(5)))
.unwrap_or((10_000, 3));
let mut last_error = None;
for _ in 0..=max_retries {
let request = self
.http
.post(format!(
"{}/bot{}/sendMessage",
self.endpoint,
self.token.expose_secret()
))
.json(&SendMessage { chat_id, text })
.send();
let result = match tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms.max(100)),
request,
)
.await
{
Ok(result) => result
.and_then(|response| response.error_for_status())
.map_err(|_| "telegram request failed".to_owned()),
Err(_) => Err("telegram request timed out".to_owned()),
};
match result {
Ok(_) => return Ok(()),
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| "telegram request failed".to_owned()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{runtime_provider, OutboundConfig};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn mock_server(
status: &'static str,
delay_ms: u64,
requests: Arc<AtomicUsize>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
requests.fetch_add(1, Ordering::Relaxed);
let mut buffer = [0_u8; 1024];
let _ = socket.read(&mut buffer).await;
if delay_ms > 0 {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n");
let _ = socket.write_all(response.as_bytes()).await;
}
});
format!("http://{address}")
}
fn client(endpoint: String, retries: u8, timeout_ms: u64) -> TelegramClient {
let runtime = runtime_provider(OutboundConfig {
telegram_timeout_ms: timeout_ms,
max_retries: retries,
..OutboundConfig::default()
});
TelegramClient::with_endpoint(SecretString::new("test-token".into()), runtime, endpoint)
}
#[tokio::test]
async fn mock_endpoint_success_sends_message() {
let requests = Arc::new(AtomicUsize::new(0));
let endpoint = mock_server("200 OK", 0, requests.clone()).await;
client(endpoint, 0, 1_000)
.send_text(42, "hello")
.await
.unwrap();
assert_eq!(requests.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn error_response_retries_with_bound() {
let requests = Arc::new(AtomicUsize::new(0));
let endpoint = mock_server("500 Internal Server Error", 0, requests.clone()).await;
assert!(client(endpoint, 2, 1_000)
.send_text(42, "hello")
.await
.is_err());
assert_eq!(requests.load(Ordering::Relaxed), 3);
}
#[tokio::test]
async fn timeout_retries_with_bound() {
let requests = Arc::new(AtomicUsize::new(0));
let endpoint = mock_server("200 OK", 250, requests.clone()).await;
assert!(client(endpoint, 1, 100)
.send_text(42, "hello")
.await
.is_err());
// The first request is guaranteed to time out; reqwest may cancel the
// second connection before the tiny test server accepts it.
assert!(requests.load(Ordering::Relaxed) >= 1);
}
}
}

468
src/config.rs Normal file
View File

@ -0,0 +1,468 @@
use secrecy::{ExposeSecret, SecretString};
use crate::error::BotError;
/// Returns the SHA-256 digest used for Redis admin-session records. The bearer token itself
/// never enters Redis (SAF-ADMIN-SESSION, C-REDIS-ONLY-STATE).
pub(crate) fn session_digest(token: &str) -> String {
let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
digest
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub fn encryption_key_from_env() -> Result<[u8; 32], BotError> {
let value = std::env::var("CONFIG_ENCRYPTION_KEY")
.map_err(|_| BotError::Config("missing CONFIG_ENCRYPTION_KEY".into()))?;
if value.len() != 64 {
return Err(BotError::Config(
"CONFIG_ENCRYPTION_KEY must be 32-byte hex".into(),
));
}
let mut key = [0_u8; 32];
for (index, slot) in key.iter_mut().enumerate() {
*slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
.map_err(|_| BotError::Config("CONFIG_ENCRYPTION_KEY must be hex".into()))?;
}
Ok(key)
}
/// Runtime-only settings. SecretString prevents accidental formatting/logging of credentials.
/// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly.
pub struct Config {
pub port: u16,
pub run_mode: String,
pub telegram: TelegramConfig,
pub jmap: JmapConfig,
pub redis_url: SecretString,
pub account_id: Option<String>,
pub llm: LlmConfig,
pub auth: AuthSecrets,
pub worker_token: SecretString,
}
/// Runtime-only endpoint credentials; never format or log these values.
/// C-NO-SECRET-IN-IMAGE and the stage-1 R1 boundary require process injection.
#[derive(Clone)]
pub struct AuthSecrets {
pub reconcile_token: SecretString,
pub telegram_webhook_secret: SecretString,
pub jmap_push_verification: SecretString,
}
pub struct TelegramConfig {
pub bot_token: SecretString,
pub chat_allowlist: Vec<i64>,
pub chat_id: i64,
}
pub struct JmapConfig {
pub session_url: String,
pub username: String,
pub app_password: SecretString,
}
pub struct LlmConfig {
pub enabled: bool,
pub allow_net: bool,
pub api_key: Option<SecretString>,
pub base_url: Option<String>,
pub model: Option<String>,
pub summary_target_chars: usize,
}
/// All business configuration persisted below the Redis `config:business` namespace.
/// Secrets intentionally have no `Debug` implementation and are never returned by the
/// public configuration API (C-REDIS-ONLY-STATE, SAF-NO-SECRET-ECHO).
#[derive(Clone)]
pub struct BusinessConfig {
pub bot_token: SecretString,
pub telegram_chat_id: i64,
pub chat_allowlist: Vec<i64>,
pub telegram_webhook_secret: SecretString,
pub jmap_session_url: String,
pub jmap_username: String,
pub jmap_password: SecretString,
pub jmap_push_verification: SecretString,
pub account_id: Option<String>,
pub llm_enabled: bool,
pub llm_allow_net: bool,
pub llm_api_key: Option<SecretString>,
pub llm_base_url: Option<String>,
pub llm_model: Option<String>,
pub reconcile_token: SecretString,
pub worker_token: SecretString,
}
/// Redis wire representation. This type is private to the persistence adapter; callers must
/// never serialize it into an HTTP response (SAF-NO-SECRET-ECHO).
#[derive(Clone, serde::Deserialize, serde::Serialize)]
pub(crate) struct BusinessConfigWire {
pub bot_token: String,
pub telegram_chat_id: i64,
pub chat_allowlist: Vec<i64>,
pub telegram_webhook_secret: String,
pub jmap_session_url: String,
pub jmap_username: String,
pub jmap_password: String,
pub jmap_push_verification: String,
pub account_id: Option<String>,
pub llm_enabled: bool,
pub llm_allow_net: bool,
pub llm_api_key: Option<String>,
pub llm_base_url: Option<String>,
pub llm_model: Option<String>,
pub reconcile_token: String,
pub worker_token: String,
}
impl From<BusinessConfig> for BusinessConfigWire {
fn from(value: BusinessConfig) -> Self {
Self {
bot_token: value.bot_token.expose_secret().to_owned(),
telegram_chat_id: value.telegram_chat_id,
chat_allowlist: value.chat_allowlist,
telegram_webhook_secret: value.telegram_webhook_secret.expose_secret().to_owned(),
jmap_session_url: value.jmap_session_url,
jmap_username: value.jmap_username,
jmap_password: value.jmap_password.expose_secret().to_owned(),
jmap_push_verification: value.jmap_push_verification.expose_secret().to_owned(),
account_id: value.account_id,
llm_enabled: value.llm_enabled,
llm_allow_net: value.llm_allow_net,
llm_api_key: value.llm_api_key.map(|s| s.expose_secret().to_owned()),
llm_base_url: value.llm_base_url,
llm_model: value.llm_model,
reconcile_token: value.reconcile_token.expose_secret().to_owned(),
worker_token: value.worker_token.expose_secret().to_owned(),
}
}
}
impl TryFrom<BusinessConfigWire> for BusinessConfig {
type Error = BotError;
fn try_from(value: BusinessConfigWire) -> Result<Self, Self::Error> {
validate_nonblank("BOT_TOKEN", &value.bot_token)?;
validate_nonblank("JMAP_SESSION_URL", &value.jmap_session_url)?;
validate_nonblank("JMAP_USERNAME", &value.jmap_username)?;
validate_nonblank("JMAP_PASSWORD", &value.jmap_password)?;
validate_nonblank("TG_WEBHOOK_SECRET", &value.telegram_webhook_secret)?;
validate_nonblank("JMAP_PUSH_VERIFICATION", &value.jmap_push_verification)?;
validate_nonblank("RECONCILE_TOKEN", &value.reconcile_token)?;
validate_nonblank("WORKER_TOKEN", &value.worker_token)?;
if let Some(key) = value.llm_api_key.as_deref() {
validate_nonblank("LLM_API_KEY", key)?;
}
Ok(Self {
bot_token: SecretString::new(value.bot_token),
telegram_chat_id: value.telegram_chat_id,
chat_allowlist: value.chat_allowlist,
telegram_webhook_secret: SecretString::new(value.telegram_webhook_secret),
jmap_session_url: value.jmap_session_url,
jmap_username: value.jmap_username,
jmap_password: SecretString::new(value.jmap_password),
jmap_push_verification: SecretString::new(value.jmap_push_verification),
account_id: value.account_id,
llm_enabled: value.llm_enabled,
llm_allow_net: value.llm_allow_net,
llm_api_key: value.llm_api_key.map(SecretString::new),
llm_base_url: value.llm_base_url,
llm_model: value.llm_model,
reconcile_token: SecretString::new(value.reconcile_token),
worker_token: SecretString::new(value.worker_token),
})
}
}
pub(crate) fn validate_business_wire(wire: BusinessConfigWire) -> Result<(), BotError> {
let config: BusinessConfig = wire.try_into()?;
let session = url::Url::parse(&config.jmap_session_url)
.map_err(|_| BotError::Config("JMAP_SESSION_URL must be a valid URL".into()))?;
if session.scheme() != "https"
|| session.host_str().is_none()
|| session.username() != ""
|| session.password().is_some()
|| session.query().is_some()
|| session.fragment().is_some()
{
return Err(BotError::Config(
"JMAP_SESSION_URL must be HTTPS without credentials or query".into(),
));
}
if config.llm_enabled {
let base = config
.llm_base_url
.as_deref()
.ok_or_else(|| BotError::Config("LLM_BASE_URL required when LLM enabled".into()))?;
if config.llm_api_key.is_none() || config.llm_model.as_deref().is_none() {
return Err(BotError::Config(
"LLM credentials required when LLM enabled".into(),
));
}
let endpoint = url::Url::parse(base)
.map_err(|_| BotError::Config("LLM_BASE_URL must be a valid URL".into()))?;
if endpoint.scheme() != "https" {
return Err(BotError::Config("LLM_BASE_URL must use HTTPS".into()));
}
}
Ok(())
}
impl Config {
/// Configuration-only startup used before Redis bootstrap. No business route can pass
/// authentication with these empty sentinels; the process only serves SPA/bootstrap.
pub fn redis_only(redis_url: SecretString) -> Self {
let empty = || SecretString::new(String::new());
Self {
port: 8080,
run_mode: "webhook".into(),
telegram: TelegramConfig {
bot_token: empty(),
chat_allowlist: Vec::new(),
chat_id: 0,
},
jmap: JmapConfig {
session_url: String::new(),
username: String::new(),
app_password: empty(),
},
redis_url,
account_id: None,
llm: LlmConfig {
enabled: false,
allow_net: false,
api_key: None,
base_url: None,
model: None,
summary_target_chars: 300,
},
auth: AuthSecrets {
reconcile_token: empty(),
telegram_webhook_secret: empty(),
jmap_push_verification: empty(),
},
worker_token: empty(),
}
}
/// Builds the runtime adapter configuration from the Redis business namespace.
/// This path is used after bootstrap and intentionally does not consult process env.
pub fn from_business(redis_url: SecretString, value: BusinessConfig) -> Self {
Self {
port: 8080,
run_mode: "webhook".into(),
telegram: TelegramConfig {
bot_token: value.bot_token,
chat_allowlist: value.chat_allowlist,
chat_id: value.telegram_chat_id,
},
jmap: JmapConfig {
session_url: value.jmap_session_url,
username: value.jmap_username,
app_password: value.jmap_password,
},
redis_url,
account_id: value.account_id,
llm: LlmConfig {
enabled: value.llm_enabled,
allow_net: value.llm_allow_net,
api_key: value.llm_api_key,
base_url: value.llm_base_url,
model: value.llm_model,
summary_target_chars: 300,
},
auth: AuthSecrets {
reconcile_token: value.reconcile_token,
telegram_webhook_secret: value.telegram_webhook_secret,
jmap_push_verification: value.jmap_push_verification,
},
worker_token: value.worker_token,
}
}
pub fn from_business_json(
redis_url: SecretString,
value: serde_json::Value,
) -> Result<Self, BotError> {
let wire: BusinessConfigWire = serde_json::from_value(value)
.map_err(|_| BotError::Config("invalid Redis business configuration".into()))?;
Self::from_business_value(redis_url, wire)
}
fn from_business_value(
redis_url: SecretString,
wire: BusinessConfigWire,
) -> Result<Self, BotError> {
Ok(Self::from_business(redis_url, wire.try_into()?))
}
pub fn from_env() -> Result<Self, BotError> {
let port = std::env::var("PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.map_err(|_| BotError::Config("PORT must be a valid u16".into()))?;
let allowlist = std::env::var("CHAT_ALLOWLIST")
.or_else(|_| std::env::var("TELEGRAM_CHAT_ID"))
.unwrap_or_default()
.split(',')
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim().parse::<i64>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| BotError::Config("CHAT_ALLOWLIST must contain integer IDs".into()))?;
let enabled = env_bool("LLM_ENABLED", false)?;
let llm = LlmConfig {
enabled,
allow_net: env_bool("LLM_ALLOW_NET", false)?,
api_key: std::env::var("LLM_API_KEY").ok().map(SecretString::new),
base_url: std::env::var("LLM_BASE_URL").ok(),
model: std::env::var("LLM_MODEL").ok(),
summary_target_chars: std::env::var("LLM_SUMMARY_TARGET_CHARS")
.unwrap_or_else(|_| "300".into())
.parse()
.map_err(|_| {
BotError::Config("LLM_SUMMARY_TARGET_CHARS must be an integer".into())
})?,
};
if enabled && (llm.api_key.is_none() || llm.base_url.is_none() || llm.model.is_none()) {
return Err(BotError::Config(
"LLM_ENABLED requires LLM_API_KEY, LLM_BASE_URL and LLM_MODEL".into(),
));
}
Ok(Self {
port,
run_mode: std::env::var("RUN_MODE").unwrap_or_else(|_| "webhook".into()),
telegram: TelegramConfig {
bot_token: required_secret("BOT_TOKEN")?,
chat_allowlist: allowlist,
chat_id: std::env::var("TELEGRAM_CHAT_ID")
.map_err(|_| BotError::Config("missing TELEGRAM_CHAT_ID".into()))?
.parse()
.map_err(|_| BotError::Config("TELEGRAM_CHAT_ID must be an integer".into()))?,
},
jmap: JmapConfig {
session_url: required_nonblank("JMAP_SESSION_URL")?,
username: required_nonblank("JMAP_USERNAME")?,
app_password: required_secret("JMAP_PASSWORD")?,
},
redis_url: required_secret("REDIS_URL")?,
account_id: std::env::var("ACCOUNT_ID").ok().filter(|v| !v.is_empty()),
llm,
auth: AuthSecrets {
reconcile_token: required_secret("RECONCILE_TOKEN")?,
telegram_webhook_secret: required_secret("TG_WEBHOOK_SECRET")?,
jmap_push_verification: required_secret("JMAP_PUSH_VERIFICATION")?,
},
worker_token: required_secret("WORKER_TOKEN")?,
})
}
/// Exposes a secret only to the client adapter at the point of use; never log this value.
#[expect(
dead_code,
reason = "稳定ID+阶段0占位:JMAP adapter 后续使用密钥访问器"
)]
pub fn jmap_password(&self) -> &str {
self.jmap.app_password.expose_secret()
}
}
fn required_nonblank(name: &str) -> Result<String, BotError> {
let value = std::env::var(name).map_err(|_| BotError::Config(format!("missing {name}")))?;
validate_nonblank(name, value.as_str())?;
Ok(value)
}
fn required_secret(name: &str) -> Result<SecretString, BotError> {
let value = required_nonblank(name)?;
Ok(SecretString::new(value))
}
fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> {
if value.trim().is_empty() {
return Err(BotError::Config(format!("missing or blank {name}")));
}
Ok(())
}
fn env_bool(name: &str, default: bool) -> Result<bool, BotError> {
match std::env::var(name).ok().as_deref() {
None => Ok(default),
Some("true" | "1" | "yes") => Ok(true),
Some("false" | "0" | "no") => Ok(false),
Some(_) => Err(BotError::Config(format!("{name} must be boolean"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blank_auth_secret_is_rejected_without_exposing_value() {
let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("unset must fail");
assert!(error.to_string().contains("S1_TEST_AUTH_SECRET"));
assert!(!error.to_string().contains("secret"));
std::env::set_var("S1_TEST_AUTH_SECRET", " \t");
let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("blank must fail");
assert!(error.to_string().contains("S1_TEST_AUTH_SECRET"));
std::env::remove_var("S1_TEST_AUTH_SECRET");
}
#[test]
fn required_config_values_reject_blank_without_exposing_values() {
for name in [
"BOT_TOKEN",
"JMAP_SESSION_URL",
"JMAP_USERNAME",
"JMAP_PASSWORD",
"REDIS_URL",
] {
let error = validate_nonblank(name, " \t").expect_err("blank must fail");
assert_eq!(
error.to_string(),
format!("configuration error: missing or blank {name}")
);
}
}
#[test]
fn startup_rejects_blank_auth_secret() {
for (name, value) in [
("BOT_TOKEN", "bot"),
("JMAP_SESSION_URL", "https://mail.invalid/.well-known/jmap"),
("JMAP_USERNAME", "user"),
("JMAP_PASSWORD", "password"),
("REDIS_URL", "redis://invalid"),
("TG_WEBHOOK_SECRET", "telegram"),
("JMAP_PUSH_VERIFICATION", "verification"),
("TELEGRAM_CHAT_ID", "123"),
] {
std::env::set_var(name, value);
}
std::env::set_var("RECONCILE_TOKEN", " \t");
let error = match Config::from_env() {
Err(error) => error,
Ok(_) => panic!("blank auth secret must fail startup"),
};
assert_eq!(
error.to_string(),
"configuration error: missing or blank RECONCILE_TOKEN"
);
for name in [
"BOT_TOKEN",
"JMAP_SESSION_URL",
"JMAP_USERNAME",
"JMAP_PASSWORD",
"REDIS_URL",
"RECONCILE_TOKEN",
"TG_WEBHOOK_SECRET",
"JMAP_PUSH_VERIFICATION",
"TELEGRAM_CHAT_ID",
] {
std::env::remove_var(name);
}
}
}

16
src/domain.rs Normal file
View File

@ -0,0 +1,16 @@
//! Channel-independent domain values. Telegram/teloxide types must not cross this boundary.
pub mod jmap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserCommand {
pub text: String,
pub chat_id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notification {
pub sender: String,
pub subject: String,
pub received_at: String,
}

222
src/domain/jmap.rs Normal file
View File

@ -0,0 +1,222 @@
//! Read-only JMAP domain boundary (REQ-SINGLE-ACCOUNT, REQ-LONG-EMAIL).
//!
//! The adapter returns channel-neutral values. In particular, `read_email` returns
//! the original text supplied by JMAP; truncation and Telegram formatting belong to
//! the caller. There are deliberately no mutation, attachment, AI, or streaming APIs.
pub mod client;
use async_trait::async_trait;
use thiserror::Error;
/// Errors exposed by the domain boundary. Client details are retained for callers,
/// while credentials are never included in these messages.
#[derive(Debug, Error)]
pub enum JmapError {
#[error("JMAP client error")]
Client(#[from] jmap_client::Error),
#[error("JMAP object was not found")]
NotFound,
#[error("invalid JMAP request: {0}")]
InvalidRequest(&'static str),
#[error("JMAP field is missing: {0}")]
MissingField(&'static str),
#[error("JMAP request timed out")]
Timeout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Folder {
pub id: String,
pub name: String,
pub role: Option<String>,
pub total_emails: usize,
pub unread_emails: usize,
}
/// Metadata only: no body or attachment is loaded by this operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmailMetadata {
pub id: String,
pub subject: Option<String>,
pub sender: Option<String>,
pub received_at: Option<i64>,
pub preview: Option<String>,
pub size: Option<usize>,
pub has_attachment: bool,
}
/// Original message content plus length information for the presentation layer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmailContent {
pub metadata: EmailMetadata,
pub text: String,
pub character_count: usize,
pub is_long: bool,
}
#[async_trait]
pub trait JmapBackend: Send + Sync {
async fn list_folders(&self, account_id: &str) -> Result<Vec<Folder>, JmapError>;
async fn list_emails(
&self,
account_id: &str,
folder_id: Option<&str>,
limit: usize,
) -> Result<Vec<EmailMetadata>, JmapError>;
async fn read_email(
&self,
account_id: &str,
email_id: &str,
) -> Result<Option<EmailContent>, JmapError>;
}
/// Single-account service. `long_email_limit` only annotates output; it never
/// truncates source content, leaving display policy to the channel adapter.
pub struct JmapService<B> {
backend: B,
account_id: String,
long_email_limit: usize,
}
impl<B> JmapService<B> {
pub fn account_id(&self) -> &str {
&self.account_id
}
pub fn new(backend: B, account_id: impl Into<String>) -> Result<Self, JmapError> {
Self::with_long_email_limit(backend, account_id, 4_000)
}
pub fn with_long_email_limit(
backend: B,
account_id: impl Into<String>,
long_email_limit: usize,
) -> Result<Self, JmapError> {
let account_id = account_id.into();
if account_id.trim().is_empty() {
return Err(JmapError::InvalidRequest("account_id"));
}
if long_email_limit == 0 {
return Err(JmapError::InvalidRequest("long_email_limit"));
}
Ok(Self {
backend,
account_id,
long_email_limit,
})
}
}
impl<B: JmapBackend> JmapService<B> {
pub async fn list_folders(&self) -> Result<Vec<Folder>, JmapError> {
self.backend.list_folders(&self.account_id).await
}
pub async fn list_emails(
&self,
folder_id: Option<&str>,
limit: usize,
) -> Result<Vec<EmailMetadata>, JmapError> {
if limit == 0 {
return Err(JmapError::InvalidRequest("limit"));
}
self.backend
.list_emails(&self.account_id, folder_id, limit)
.await
}
pub async fn read_email(&self, email_id: &str) -> Result<Option<EmailContent>, JmapError> {
if email_id.trim().is_empty() {
return Err(JmapError::InvalidRequest("email_id"));
}
let Some(mut content) = self.backend.read_email(&self.account_id, email_id).await? else {
return Ok(None);
};
content.character_count = content.text.chars().count();
content.is_long = content.character_count > self.long_email_limit;
Ok(Some(content))
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockBackend;
#[async_trait]
impl JmapBackend for MockBackend {
async fn list_folders(&self, account_id: &str) -> Result<Vec<Folder>, JmapError> {
assert_eq!(account_id, "account-1");
Ok(vec![Folder {
id: "inbox".into(),
name: "Inbox".into(),
role: Some("inbox".into()),
total_emails: 1,
unread_emails: 1,
}])
}
async fn list_emails(
&self,
account_id: &str,
folder_id: Option<&str>,
limit: usize,
) -> Result<Vec<EmailMetadata>, JmapError> {
assert_eq!(
(account_id, folder_id, limit),
("account-1", Some("inbox"), 10)
);
Ok(vec![EmailMetadata {
id: "email-1".into(),
subject: Some("hello".into()),
sender: None,
received_at: None,
preview: Some("hello".into()),
size: Some(5),
has_attachment: false,
}])
}
async fn read_email(
&self,
account_id: &str,
email_id: &str,
) -> Result<Option<EmailContent>, JmapError> {
assert_eq!((account_id, email_id), ("account-1", "email-1"));
Ok(Some(EmailContent {
metadata: EmailMetadata {
id: email_id.into(),
subject: None,
sender: None,
received_at: None,
preview: None,
size: None,
has_attachment: false,
},
text: "原文内容".into(),
character_count: 0,
is_long: false,
}))
}
}
#[tokio::test]
async fn service_keeps_original_content_and_marks_long_messages() {
let service = JmapService::with_long_email_limit(MockBackend, "account-1", 2).unwrap();
assert_eq!(service.list_folders().await.unwrap().len(), 1);
assert_eq!(
service.list_emails(Some("inbox"), 10).await.unwrap().len(),
1
);
let email = service.read_email("email-1").await.unwrap().unwrap();
assert_eq!(email.text, "原文内容");
assert_eq!(email.character_count, 4);
assert!(email.is_long);
}
#[test]
fn service_rejects_invalid_limits_and_account() {
assert!(JmapService::new(MockBackend, " ").is_err());
assert!(JmapService::with_long_email_limit(MockBackend, "a", 0).is_err());
}
}

386
src/domain/jmap/client.rs Normal file
View File

@ -0,0 +1,386 @@
//! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY).
use super::{EmailContent, EmailMetadata, Folder, JmapBackend, JmapError};
use crate::state::{runtime_provider, OutboundConfig, RuntimeConfigProvider};
use async_trait::async_trait;
use jmap_client::{email, mailbox, Get};
pub struct JmapClientBackend {
client: jmap_client::client::Client,
account_id: String,
runtime: RuntimeConfigProvider,
}
impl JmapClientBackend {
pub async fn connect(
session_url: &str,
username: &str,
password: &str,
account_id: Option<&str>,
) -> Result<Self, JmapError> {
Self::connect_with_runtime(
session_url,
username,
password,
account_id,
runtime_provider(OutboundConfig::default()),
)
.await
}
pub async fn connect_with_runtime(
session_url: &str,
username: &str,
password: &str,
account_id: Option<&str>,
runtime: RuntimeConfigProvider,
) -> Result<Self, JmapError> {
let session_base = normalize_session_url(session_url)?;
let timeout_ms = runtime
.read()
.map(|config| config.jmap_timeout_ms)
.unwrap_or(15_000);
let timeout = std::time::Duration::from_millis(timeout_ms.max(100));
let connect = jmap_client::client::Client::new()
.credentials((username, password))
.connect(&session_base);
let mut client = tokio::time::timeout(timeout, connect)
.await
.map_err(|_| JmapError::Timeout)??;
let selected = select_account(
account_id,
client.default_account_id(),
client.session().accounts(),
)?;
client.set_default_account_id(selected.clone());
Ok(Self {
client,
account_id: selected,
runtime,
})
}
pub fn account_id(&self) -> &str {
&self.account_id
}
}
#[async_trait]
impl JmapBackend for JmapClientBackend {
async fn list_folders(&self, account_id: &str) -> Result<Vec<Folder>, JmapError> {
if account_id != self.account_id {
return Err(JmapError::InvalidRequest("account_id"));
}
let ids = tokio::time::timeout(
self.timeout(),
self.client.mailbox_query(
None::<mailbox::query::Filter>,
None::<Vec<jmap_client::core::query::Comparator<mailbox::query::Comparator>>>,
),
)
.await
.map_err(|_| JmapError::Timeout)??
.take_ids();
let mut folders = Vec::with_capacity(ids.len());
for id in ids {
if let Some(mailbox) = tokio::time::timeout(
self.timeout(),
self.client.mailbox_get(&id, None::<Vec<mailbox::Property>>),
)
.await
.map_err(|_| JmapError::Timeout)??
{
folders.push(Folder {
id: mailbox
.id()
.ok_or(JmapError::MissingField("mailbox id"))?
.to_owned(),
name: mailbox.name().unwrap_or_default().to_owned(),
role: role_name(mailbox.role()),
total_emails: mailbox.total_emails(),
unread_emails: mailbox.unread_emails(),
});
}
}
Ok(folders)
}
async fn list_emails(
&self,
account_id: &str,
folder_id: Option<&str>,
limit: usize,
) -> Result<Vec<EmailMetadata>, JmapError> {
if account_id != self.account_id || limit == 0 {
return Err(JmapError::InvalidRequest("email query"));
}
let filter: Option<email::query::Filter> = folder_id.map(email::query::Filter::in_mailbox);
let ids = tokio::time::timeout(
self.timeout(),
self.client.email_query(
filter,
None::<Vec<jmap_client::core::query::Comparator<email::query::Comparator>>>,
),
)
.await
.map_err(|_| JmapError::Timeout)??
.take_ids();
let mut out = Vec::new();
for id in ids.into_iter().take(limit) {
if let Some(email) = tokio::time::timeout(
self.timeout(),
self.client.email_get(&id, None::<Vec<email::Property>>),
)
.await
.map_err(|_| JmapError::Timeout)??
{
out.push(metadata(&email));
}
}
Ok(out)
}
async fn read_email(
&self,
account_id: &str,
email_id: &str,
) -> Result<Option<EmailContent>, JmapError> {
if account_id != self.account_id {
return Err(JmapError::InvalidRequest("account_id"));
}
let mut request = self.client.build();
let get = request.get_email().ids([email_id]).properties([
email::Property::Subject,
email::Property::Preview,
email::Property::TextBody,
email::Property::BodyValues,
email::Property::Size,
email::Property::ReceivedAt,
email::Property::HasAttachment,
]);
get.arguments().fetch_text_body_values(true);
let Some(email) = tokio::time::timeout(self.timeout(), request.send_get_email())
.await
.map_err(|_| JmapError::Timeout)??
.take_list()
.pop()
else {
return Ok(None);
};
let text = join_body_values(email.text_body().into_iter().flatten().map(|part| {
part.part_id()
.and_then(|id| email.body_value(id))
.map(|body| body.value())
}))?;
Ok(Some(EmailContent {
metadata: metadata(&email),
character_count: text.chars().count(),
is_long: false,
text,
}))
}
}
impl JmapClientBackend {
fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(
self.runtime
.read()
.map(|config| config.jmap_timeout_ms)
.unwrap_or(15_000)
.max(100),
)
}
}
fn join_body_values<'a>(
values: impl Iterator<Item = Option<&'a str>>,
) -> Result<String, JmapError> {
let text = values.flatten().collect::<String>();
if text.is_empty() {
Err(JmapError::MissingField("text body value"))
} else {
Ok(text)
}
}
fn role_name(role: mailbox::Role) -> Option<String> {
match role {
mailbox::Role::Archive => Some("archive".into()),
mailbox::Role::Drafts => Some("drafts".into()),
mailbox::Role::Important => Some("important".into()),
mailbox::Role::Inbox => Some("inbox".into()),
mailbox::Role::Junk => Some("junk".into()),
mailbox::Role::Sent => Some("sent".into()),
mailbox::Role::Trash => Some("trash".into()),
mailbox::Role::Other(value) => Some(value),
mailbox::Role::None => None,
}
}
fn normalize_session_url(value: &str) -> Result<String, JmapError> {
let parsed =
url::Url::parse(value).map_err(|_| JmapError::InvalidRequest("JMAP_SESSION_URL"))?;
if parsed.scheme() != "https" {
return Err(JmapError::InvalidRequest("JMAP_SESSION_URL must use HTTPS"));
}
if !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(JmapError::InvalidRequest(
"JMAP_SESSION_URL must not contain credentials or query",
));
}
if !matches!(parsed.path(), "" | "/" | "/.well-known/jmap") {
return Err(JmapError::InvalidRequest("invalid JMAP_SESSION_URL path"));
}
Ok(parsed.origin().ascii_serialization())
}
fn select_account<'a>(
requested: Option<&str>,
primary: &'a str,
mut accounts: impl Iterator<Item = &'a String>,
) -> Result<String, JmapError> {
let selected = requested
.filter(|id| !id.trim().is_empty())
.unwrap_or(primary);
if selected.is_empty() {
return Err(JmapError::MissingField("primary account"));
}
if !accounts.any(|id| id == selected) {
return Err(JmapError::InvalidRequest(
"ACCOUNT_ID is not in session accounts",
));
}
Ok(selected.to_owned())
}
fn metadata(email: &jmap_client::email::Email<Get>) -> EmailMetadata {
EmailMetadata {
id: email.id().unwrap_or_default().to_owned(),
subject: email.subject().map(str::to_owned),
sender: email
.from()
.and_then(|addresses| addresses.first())
.map(|address| address.email().to_owned()),
received_at: email.received_at(),
preview: email.preview().map(str::to_owned),
size: Some(email.size()),
has_attachment: email.has_attachment(),
}
}
#[cfg(test)]
mod real_server_tests {
use super::*;
/// Opt-in smoke test for a real Stalwart instance; credentials are never printed.
#[tokio::test]
#[ignore = "requires an explicitly configured JMAP test server"]
async fn session_list_and_read_smoke() {
let Some(url) = std::env::var_os("JMAP_SESSION_URL") else {
eprintln!("skipped: JMAP_SESSION_URL is not configured");
return;
};
let Some(user) = std::env::var_os("JMAP_USERNAME") else {
eprintln!("skipped: JMAP_USERNAME is not configured");
return;
};
let Some(password) = std::env::var_os("JMAP_PASSWORD") else {
eprintln!("skipped: JMAP_PASSWORD is not configured");
return;
};
let account = std::env::var("ACCOUNT_ID").ok();
let backend = JmapClientBackend::connect(
url.to_str().unwrap_or_default(),
user.to_str().unwrap_or_default(),
password.to_str().unwrap_or_default(),
account.as_deref(),
)
.await
.expect("configured JMAP session must connect");
let folders = backend.list_folders(backend.account_id()).await.unwrap();
let emails = backend
.list_emails(backend.account_id(), None, 1)
.await
.unwrap();
if let Some(email) = emails.first() {
let _ = backend
.read_email(backend.account_id(), &email.id)
.await
.unwrap();
}
assert!(!folders.is_empty() || emails.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::jmap::JmapService;
fn accounts(values: &[&str]) -> impl Iterator<Item = &'static String> {
let owned = values
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>();
Box::leak(owned.into_boxed_slice()).iter()
}
#[test]
fn account_selection_supports_primary_and_validates_explicit() {
assert_eq!(
select_account(None, "primary", accounts(&["primary"])).unwrap(),
"primary"
);
assert_eq!(
select_account(
Some("secondary"),
"primary",
accounts(&["primary", "secondary"])
)
.unwrap(),
"secondary"
);
assert!(select_account(Some("missing"), "primary", accounts(&["primary"])).is_err());
}
#[test]
fn session_url_and_limit_boundaries_are_rejected() {
assert!(normalize_session_url("http://mail.example").is_err());
assert_eq!(
normalize_session_url("https://mail.example/.well-known/jmap").unwrap(),
"https://mail.example"
);
assert_eq!(
normalize_session_url("https://mail.example").unwrap(),
"https://mail.example"
);
assert!(normalize_session_url("https://u:p@mail.example").is_err());
assert!(normalize_session_url("https://mail.example/?token=x").is_err());
assert!(normalize_session_url("https://mail.example/#fragment").is_err());
assert!(normalize_session_url("https://mail.example/mail/jmap").is_err());
assert!(JmapService::with_long_email_limit((), "account", 0).is_err());
}
#[test]
fn body_values_join_in_order_and_empty_is_explicit_error() {
assert_eq!(
join_body_values([Some("first "), None, Some("second")].into_iter()).unwrap(),
"first second"
);
assert!(matches!(
join_body_values([None, None].into_iter()),
Err(JmapError::MissingField("text body value"))
));
}
#[test]
fn roles_are_normalized() {
assert_eq!(role_name(mailbox::Role::Inbox).as_deref(), Some("inbox"));
assert_eq!(role_name(mailbox::Role::None), None);
}
}

13
src/error.rs Normal file
View File

@ -0,0 +1,13 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BotError {
#[error("configuration error: {0}")]
Config(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("state error")]
State(#[from] crate::state::StateError),
}

153
src/main.rs Normal file
View File

@ -0,0 +1,153 @@
mod ai;
mod channel;
mod config;
pub mod domain;
mod error;
mod notify;
pub mod state;
mod web;
mod worker;
use secrecy::ExposeSecret;
use std::net::SocketAddr;
use crate::channel::telegram::TelegramClient;
use crate::domain::jmap::{client::JmapClientBackend, JmapService};
use config::{encryption_key_from_env, Config};
use notify::{
router_with_worker_state_runtime_bootstrap, MetadataWorker, WorkerHandle, WorkerHandler,
};
use state::{runtime_provider, RedisState};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), error::BotError> {
tracing_subscriber::fmt().with_env_filter("info").init();
// The Redis URL is the only bootstrap credential in the migration path. Until
// the bootstrap route is wired, retain the legacy parser solely to obtain it.
let bootstrap_config = match Config::from_env() {
Ok(config) => config,
Err(_) => {
let redis_url = std::env::var("REDIS_URL")
.map_err(|_| error::BotError::Config("missing REDIS_URL".into()))?;
Config::redis_only(secrecy::SecretString::new(redis_url))
}
};
let encryption_key = encryption_key_from_env()?;
let redis_url = bootstrap_config.redis_url.clone();
// RUN_MODE is intentionally explicit even while both modes share the stage-0
// no-op router; later stages attach webhook/reconcile side effects here.
match bootstrap_config.run_mode.as_str() {
"webhook" => tracing::info!("RUN_MODE=webhook"),
"reconcile" => tracing::info!("RUN_MODE=reconcile"),
mode => {
return Err(error::BotError::Config(format!(
"RUN_MODE must be webhook or reconcile, got {mode}"
)))
}
}
let addr: SocketAddr = format!("0.0.0.0:{}", bootstrap_config.port)
.parse()
.map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?;
tracing::info!(
port = bootstrap_config.port,
"starting webhook HTTP entrypoint"
);
let listener = tokio::net::TcpListener::bind(addr).await?;
let redis =
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
.await?;
let bootstrap_token = url::Url::parse(redis_url.expose_secret())
.ok()
.and_then(|value| value.password().map(ToOwned::to_owned))
.map(secrecy::SecretString::new)
.unwrap_or_else(|| secrecy::SecretString::new(String::new()));
let worker_state: std::sync::Arc<dyn state::ReliableState> = std::sync::Arc::new(
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
.await?,
);
let config = match worker_state
.get_business_config()
.await
.map_err(|_| error::BotError::Config("business config unavailable".into()))?
{
Some(value) => Config::from_business_json(redis_url.clone(), value)?,
None => bootstrap_config,
};
let outbound = worker_state
.get_outbound_config()
.await
.map_err(|_| error::BotError::Config("runtime outbound config unavailable".into()))?;
let runtime = runtime_provider(outbound.clone());
let llm = if config.llm.enabled && config.llm.allow_net {
Some(std::sync::Arc::new(
ai::LlmClient::with_runtime(
config.llm.base_url.clone().unwrap_or_default(),
config
.llm
.api_key
.clone()
.unwrap_or_else(|| secrecy::SecretString::new("".into())),
config.llm.model.clone().unwrap_or_default(),
config.llm.summary_target_chars,
runtime.clone(),
)
.map_err(|_| error::BotError::Config("LLM endpoint must use HTTPS".into()))?,
))
} else {
None
};
let allowlist: std::collections::HashSet<i64> =
config.telegram.chat_allowlist.iter().copied().collect();
let chat_id = config.telegram.chat_id;
let worker: Arc<dyn WorkerHandler> = if config.telegram.bot_token.expose_secret().is_empty() {
Arc::new(notify::NoopWorker)
} else {
match JmapClientBackend::connect_with_runtime(
&config.jmap.session_url,
&config.jmap.username,
config.jmap.app_password.expose_secret(),
config.account_id.as_deref(),
runtime.clone(),
// All subsequent JMAP calls read this provider, so SPA updates apply
// without restarting the process.
)
.await
{
Ok(backend) => {
let account = backend.account_id().to_owned();
match JmapService::new(backend, account) {
Ok(jmap) => Arc::new(MetadataWorker::new(
jmap,
TelegramClient::with_runtime(config.telegram.bot_token, runtime.clone()),
chat_id,
worker_state,
llm,
)) as Arc<dyn WorkerHandler>,
Err(_) => {
tracing::warn!(
"JMAP service unavailable; starting configuration-only mode"
);
Arc::new(notify::NoopWorker)
}
}
}
Err(_) => {
tracing::warn!("JMAP connection unavailable; starting configuration-only mode");
Arc::new(notify::NoopWorker)
}
}
};
let app = router_with_worker_state_runtime_bootstrap(
config.auth,
config.worker_token,
redis,
allowlist,
Arc::new(WorkerHandle::new(worker)),
runtime,
bootstrap_token,
)
.merge(web::router());
axum::serve(listener, app).await?;
Ok(())
}

1045
src/notify.rs Normal file

File diff suppressed because it is too large Load Diff

846
src/state.rs Normal file
View File

@ -0,0 +1,846 @@
//! Redis-only reliability primitives (C-REDIS-ONLY-STATE, MOD-DEDUP, MOD-STREAMS).
//! These APIs are deliberately short-request operations; no subscriptions or polling.
use crate::config::BusinessConfig;
use async_trait::async_trait;
use ring::{
aead,
rand::{SecureRandom, SystemRandom},
};
use std::{
collections::{HashMap, HashSet},
sync::{Arc, Mutex, RwLock},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum StateError {
#[error("redis operation failed")]
Redis(#[from] redis::RedisError),
#[error("state lock poisoned")]
Poisoned,
#[error("configuration encryption failed")]
Encryption,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StreamMessage {
pub id: String,
pub payload: String,
}
/// Non-secret outbound tuning persisted in Redis for SPA/API management.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct OutboundConfig {
pub jmap_timeout_ms: u64,
pub telegram_timeout_ms: u64,
pub llm_timeout_ms: u64,
pub max_retries: u8,
}
pub type RuntimeConfigProvider = Arc<RwLock<OutboundConfig>>;
pub type RuntimeBusinessConfigProvider = Arc<RwLock<Option<BusinessConfig>>>;
pub fn runtime_provider(config: OutboundConfig) -> RuntimeConfigProvider {
Arc::new(RwLock::new(config))
}
pub fn business_runtime_provider(config: Option<BusinessConfig>) -> RuntimeBusinessConfigProvider {
Arc::new(RwLock::new(config))
}
impl Default for OutboundConfig {
fn default() -> Self {
Self {
jmap_timeout_ms: 15_000,
telegram_timeout_ms: 10_000,
llm_timeout_ms: 30_000,
max_retries: 3,
}
}
}
#[async_trait]
pub trait ReliableState: Send + Sync {
/// Atomic SET NX EX claim for Telegram update or `(account,email)` dedup key.
async fn claim_dedup(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError>;
/// Checks a durable idempotency commit without creating one.
async fn dedup_exists(&self, key: &str) -> Result<bool, StateError>;
/// Roll back a dedup claim when the following enqueue operation fails.
async fn release_dedup(&self, key: &str) -> Result<(), StateError>;
/// XADD enqueue; consumers perform XREADGROUP in a later worker phase.
async fn enqueue(&self, stream: &str, payload: &str) -> Result<String, StateError>;
/// XACK after successful processing.
async fn ack(&self, stream: &str, group: &str, message_id: &str) -> Result<(), StateError>;
/// XADD to a dead-letter stream after bounded retry policy decides to stop retrying.
async fn dead_letter(&self, stream: &str, payload: &str) -> Result<String, StateError>;
/// SET NX EX lock for reconcile single-flight.
async fn acquire_lock(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError>;
/// Release a lock after the bounded reconcile attempt has completed.
async fn release_lock(&self, key: &str) -> Result<(), StateError>;
async fn read_batch(
&self,
stream: &str,
group: &str,
consumer: &str,
count: usize,
) -> Result<Vec<StreamMessage>, StateError>;
async fn retry_or_dlq(
&self,
stream: &str,
dlq: &str,
group: &str,
message: &StreamMessage,
max_attempts: u32,
) -> Result<bool, StateError>;
async fn set_ai_consent(&self, chat_id: i64, ttl_seconds: u64) -> Result<(), StateError>;
async fn ai_consent_until(&self, chat_id: i64) -> Result<Option<i64>, StateError>;
async fn clear_ai_consent(&self, chat_id: i64) -> Result<(), StateError>;
async fn get_outbound_config(&self) -> Result<OutboundConfig, StateError>;
async fn set_outbound_config(&self, config: &OutboundConfig) -> Result<(), StateError>;
/// Atomically read the encrypted-at-rest business configuration namespace.
/// Callers must redact secrets before returning it to HTTP clients.
async fn get_business_config(&self) -> Result<Option<serde_json::Value>, StateError>;
/// Replace the business configuration namespace; bootstrap callers must enforce
/// SET-NX semantics before invoking this operation (C-REDIS-ONLY-STATE).
async fn set_business_config(&self, config: &serde_json::Value) -> Result<(), StateError>;
/// Atomically initialize the business namespace; returns false when already set.
async fn initialize_business_config(
&self,
config: &serde_json::Value,
) -> Result<bool, StateError>;
/// Stores only the opaque session digest, never the bearer token itself.
async fn put_admin_session(&self, digest: &str, ttl_seconds: u64) -> Result<(), StateError>;
async fn admin_session_valid(&self, digest: &str) -> Result<bool, StateError>;
async fn revoke_admin_session(&self, digest: &str) -> Result<(), StateError>;
async fn business_config_revision(&self) -> Result<u64, StateError>;
}
pub struct RedisState {
connection: redis::aio::MultiplexedConnection,
encryption_key: Option<[u8; 32]>,
}
impl RedisState {
/// Connects to externally managed REDIS_URL; URL/password are never logged.
pub async fn connect(redis_url: &str) -> Result<Self, StateError> {
Self::connect_with_encryption(redis_url, None).await
}
pub async fn connect_with_encryption(
redis_url: &str,
encryption_key: Option<[u8; 32]>,
) -> Result<Self, StateError> {
let client = redis::Client::open(redis_url)?;
Ok(Self {
connection: client.get_multiplexed_async_connection().await?,
encryption_key,
})
}
}
#[async_trait]
impl ReliableState for RedisState {
async fn claim_dedup(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError> {
set_nx_ex(self.connection.clone(), key, ttl_seconds).await
}
async fn release_dedup(&self, key: &str) -> Result<(), StateError> {
delete_key(self.connection.clone(), key).await
}
async fn dedup_exists(&self, key: &str) -> Result<bool, StateError> {
let mut connection = self.connection.clone();
let exists: i64 = redis::cmd("EXISTS")
.arg(key)
.query_async(&mut connection)
.await?;
Ok(exists != 0)
}
async fn enqueue(&self, stream: &str, payload: &str) -> Result<String, StateError> {
let mut connection = self.connection.clone();
Ok(redis::cmd("XADD")
.arg(stream)
.arg("MAXLEN")
.arg("~")
.arg(10_000)
.arg("*")
.arg("payload")
.arg(payload)
.query_async(&mut connection)
.await?)
}
async fn ack(&self, stream: &str, group: &str, message_id: &str) -> Result<(), StateError> {
let mut connection = self.connection.clone();
let _: i64 = redis::cmd("XACK")
.arg(stream)
.arg(group)
.arg(message_id)
.query_async(&mut connection)
.await?;
Ok(())
}
async fn dead_letter(&self, stream: &str, payload: &str) -> Result<String, StateError> {
self.enqueue(stream, payload).await
}
async fn acquire_lock(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError> {
set_nx_ex(self.connection.clone(), key, ttl_seconds).await
}
async fn release_lock(&self, key: &str) -> Result<(), StateError> {
delete_key(self.connection.clone(), key).await
}
async fn read_batch(
&self,
stream: &str,
group: &str,
consumer: &str,
count: usize,
) -> Result<Vec<StreamMessage>, StateError> {
let mut connection = self.connection.clone();
// The worker is deployable from an empty Redis: create its group lazily.
// BUSYGROUP is harmless when another instance created it first.
let _: Result<String, redis::RedisError> = redis::cmd("XGROUP")
.arg("CREATE")
.arg(stream)
.arg(group)
.arg("0-0")
.arg("MKSTREAM")
.query_async(&mut connection)
.await;
// Recover messages left in the consumer group's PEL after a crash or
// restart before reading new entries. A bounded idle window prevents
// two short-lived workers from stealing active work.
let reclaimed: redis::streams::StreamAutoClaimReply = redis::cmd("XAUTOCLAIM")
.arg(stream)
.arg(group)
.arg(consumer)
// A worker handles at most ten events per request; keep this
// above the worst bounded JMAP/Telegram batch duration.
.arg(300_000)
.arg("0-0")
.arg("COUNT")
.arg(count.max(1))
.query_async(&mut connection)
.await?;
let reclaimed_messages = reclaimed
.claimed
.into_iter()
.filter_map(stream_id_to_message)
.collect::<Vec<_>>();
if !reclaimed_messages.is_empty() {
return Ok(reclaimed_messages);
}
let reply: redis::streams::StreamReadReply = redis::cmd("XREADGROUP")
.arg("GROUP")
.arg(group)
.arg(consumer)
.arg("COUNT")
.arg(count.max(1))
.arg("STREAMS")
.arg(stream)
.arg(">")
.query_async(&mut connection)
.await?;
Ok(reply
.keys
.into_iter()
.flat_map(|key| key.ids.into_iter().filter_map(stream_id_to_message))
.collect())
}
async fn retry_or_dlq(
&self,
stream: &str,
dlq: &str,
group: &str,
message: &StreamMessage,
max_attempts: u32,
) -> Result<bool, StateError> {
let mut connection = self.connection.clone();
let retry_key = format!("retry:{stream}:{}", message.id);
// Keep increment, DLQ append, and source ACK in one Redis script so a
// restart cannot leave an acknowledged event out of both queues.
let script = redis::Script::new(
r#"local n = redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], 86400)
if n < tonumber(ARGV[1]) then return 0 end
redis.call('XADD', ARGV[2], 'MAXLEN', '~', 10000, '*', 'payload', ARGV[3])
redis.call('XACK', ARGV[4], ARGV[5], ARGV[6])
return 1"#,
);
let moved: i64 = script
.key(retry_key)
.arg(max_attempts.max(1))
.arg(dlq)
.arg(&message.payload)
.arg(stream)
.arg(group)
.arg(&message.id)
.invoke_async(&mut connection)
.await?;
Ok(moved != 0)
}
async fn set_ai_consent(&self, chat_id: i64, ttl_seconds: u64) -> Result<(), StateError> {
let mut connection = self.connection.clone();
let key = format!("consent:ai:{chat_id}");
let expiry = unix_now().saturating_add(ttl_seconds as i64);
redis::cmd("SET")
.arg(key)
.arg(expiry)
.arg("EX")
.arg(ttl_seconds.max(1))
.query_async::<()>(&mut connection)
.await?;
Ok(())
}
async fn ai_consent_until(&self, chat_id: i64) -> Result<Option<i64>, StateError> {
let mut connection = self.connection.clone();
let value: Option<i64> = redis::cmd("GET")
.arg(format!("consent:ai:{chat_id}"))
.query_async(&mut connection)
.await?;
Ok(value.filter(|expiry| *expiry > unix_now()))
}
async fn clear_ai_consent(&self, chat_id: i64) -> Result<(), StateError> {
let mut connection = self.connection.clone();
redis::cmd("DEL")
.arg(format!("consent:ai:{chat_id}"))
.query_async::<()>(&mut connection)
.await?;
Ok(())
}
async fn get_outbound_config(&self) -> Result<OutboundConfig, StateError> {
let mut connection = self.connection.clone();
let raw: Option<String> = redis::cmd("GET")
.arg("config:outbound")
.query_async(&mut connection)
.await?;
raw.map(|value| {
serde_json::from_str(&value).map_err(|_| {
redis::RedisError::from((redis::ErrorKind::TypeError, "invalid outbound config"))
})
})
.transpose()
.map(|config| config.unwrap_or_default())
.map_err(StateError::Redis)
}
async fn set_outbound_config(&self, config: &OutboundConfig) -> Result<(), StateError> {
let mut connection = self.connection.clone();
let value = serde_json::to_string(config).map_err(|_| {
redis::RedisError::from((redis::ErrorKind::TypeError, "serialize outbound config"))
})?;
redis::cmd("SET")
.arg("config:outbound")
.arg(value)
.query_async::<()>(&mut connection)
.await?;
Ok(())
}
async fn get_business_config(&self) -> Result<Option<serde_json::Value>, StateError> {
let mut connection = self.connection.clone();
let raw: Option<String> = redis::cmd("GET")
.arg("config:business")
.query_async(&mut connection)
.await?;
raw.map(|value| decrypt_config(self.encryption_key.as_ref(), &value))
.transpose()
.map_err(|_| StateError::Encryption)
}
async fn set_business_config(&self, config: &serde_json::Value) -> Result<(), StateError> {
let mut connection = self.connection.clone();
let value = encrypt_config(self.encryption_key.as_ref(), config)?;
redis::cmd("SET")
.arg("config:business")
.arg(value)
.query_async::<()>(&mut connection)
.await?;
redis::cmd("INCR")
.arg("config:business:revision")
.query_async::<u64>(&mut connection)
.await?;
Ok(())
}
async fn initialize_business_config(
&self,
config: &serde_json::Value,
) -> Result<bool, StateError> {
let mut connection = self.connection.clone();
let value = encrypt_config(self.encryption_key.as_ref(), config)?;
let result: Option<String> = redis::cmd("SET")
.arg("config:business")
.arg(value)
.arg("NX")
.query_async(&mut connection)
.await?;
if result.is_some() {
redis::cmd("SET")
.arg("config:business:revision")
.arg(1_u64)
.arg("NX")
.query_async::<()>(&mut connection)
.await?;
}
Ok(result.is_some())
}
async fn put_admin_session(&self, digest: &str, ttl_seconds: u64) -> Result<(), StateError> {
let mut connection = self.connection.clone();
redis::cmd("SET")
.arg("config:admin_session")
.arg(digest)
.arg("EX")
.arg(ttl_seconds.max(1))
.query_async::<()>(&mut connection)
.await?;
Ok(())
}
async fn admin_session_valid(&self, digest: &str) -> Result<bool, StateError> {
let mut connection = self.connection.clone();
let current: Option<String> = redis::cmd("GET")
.arg("config:admin_session")
.query_async(&mut connection)
.await?;
Ok(current.as_deref() == Some(digest))
}
async fn revoke_admin_session(&self, _digest: &str) -> Result<(), StateError> {
delete_key(self.connection.clone(), "config:admin_session").await
}
async fn business_config_revision(&self) -> Result<u64, StateError> {
let mut connection = self.connection.clone();
let value: Option<u64> = redis::cmd("GET")
.arg("config:business:revision")
.query_async(&mut connection)
.await?;
Ok(value.unwrap_or(0))
}
}
fn stream_id_to_message(item: redis::streams::StreamId) -> Option<StreamMessage> {
let payload = item
.map
.get("payload")
.and_then(|value| redis::from_redis_value(value).ok());
payload.map(|payload| StreamMessage {
id: item.id,
payload,
})
}
async fn set_nx_ex(
mut connection: redis::aio::MultiplexedConnection,
key: &str,
ttl_seconds: u64,
) -> Result<bool, StateError> {
let result: Option<String> = redis::cmd("SET")
.arg(key)
.arg("1")
.arg("NX")
.arg("EX")
.arg(ttl_seconds)
.query_async(&mut connection)
.await?;
Ok(result.is_some())
}
#[derive(Clone, Default)]
pub struct MemoryState {
inner: Arc<Mutex<MemoryInner>>,
}
#[derive(Default)]
struct MemoryInner {
dedup: HashSet<String>,
locks: HashSet<String>,
streams: HashMap<String, Vec<(String, String)>>,
retries: HashMap<(String, String), u32>,
consent: HashMap<i64, i64>,
next_id: u64,
outbound: OutboundConfig,
business_config: Option<serde_json::Value>,
admin_session: Option<(String, i64)>,
business_revision: u64,
}
#[async_trait]
impl ReliableState for MemoryState {
async fn claim_dedup(&self, key: &str, _ttl_seconds: u64) -> Result<bool, StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner.dedup.insert(key.to_owned()))
}
async fn release_dedup(&self, key: &str) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner.dedup.remove(key);
Ok(())
}
async fn dedup_exists(&self, key: &str) -> Result<bool, StateError> {
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner.dedup.contains(key))
}
async fn enqueue(&self, stream: &str, payload: &str) -> Result<String, StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner.next_id += 1;
let id = format!("{}-0", inner.next_id);
inner
.streams
.entry(stream.to_owned())
.or_default()
.push((id.clone(), payload.to_owned()));
if let Some(messages) = inner.streams.get_mut(stream) {
let excess = messages.len().saturating_sub(10_000);
if excess > 0 {
messages.drain(..excess);
}
}
Ok(id)
}
async fn ack(&self, stream: &str, _group: &str, message_id: &str) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
if let Some(messages) = inner.streams.get_mut(stream) {
messages.retain(|(id, _)| id != message_id);
}
Ok(())
}
async fn dead_letter(&self, stream: &str, payload: &str) -> Result<String, StateError> {
self.enqueue(stream, payload).await
}
async fn acquire_lock(&self, key: &str, _ttl_seconds: u64) -> Result<bool, StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner.locks.insert(key.to_owned()))
}
async fn release_lock(&self, key: &str) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner.locks.remove(key);
Ok(())
}
async fn read_batch(
&self,
stream: &str,
_group: &str,
_consumer: &str,
count: usize,
) -> Result<Vec<StreamMessage>, StateError> {
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner
.streams
.get(stream)
.into_iter()
.flatten()
.take(count.max(1))
.map(|(id, payload)| StreamMessage {
id: id.clone(),
payload: payload.clone(),
})
.collect())
}
async fn retry_or_dlq(
&self,
stream: &str,
dlq: &str,
_group: &str,
message: &StreamMessage,
max_attempts: u32,
) -> Result<bool, StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
let key = (stream.to_owned(), message.id.clone());
let attempts = {
let entry = inner.retries.entry(key).or_default();
*entry += 1;
*entry
};
if attempts >= max_attempts {
inner.next_id += 1;
let id = format!("{}-0", inner.next_id);
inner
.streams
.entry(dlq.to_owned())
.or_default()
.push((id, message.payload.clone()));
if let Some(messages) = inner.streams.get_mut(stream) {
messages.retain(|(source_id, _)| source_id != &message.id);
}
return Ok(true);
}
let _ = stream;
Ok(false)
}
async fn set_ai_consent(&self, chat_id: i64, ttl_seconds: u64) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner
.consent
.insert(chat_id, unix_now().saturating_add(ttl_seconds as i64));
Ok(())
}
async fn ai_consent_until(&self, chat_id: i64) -> Result<Option<i64>, StateError> {
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner
.consent
.get(&chat_id)
.copied()
.filter(|expiry| *expiry > unix_now()))
}
async fn clear_ai_consent(&self, chat_id: i64) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner.consent.remove(&chat_id);
Ok(())
}
async fn get_outbound_config(&self) -> Result<OutboundConfig, StateError> {
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner.outbound.clone())
}
async fn set_outbound_config(&self, config: &OutboundConfig) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner.outbound = config.clone();
Ok(())
}
async fn get_business_config(&self) -> Result<Option<serde_json::Value>, StateError> {
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner.business_config.clone())
}
async fn set_business_config(&self, config: &serde_json::Value) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner.business_config = Some(config.clone());
inner.business_revision = inner.business_revision.saturating_add(1);
Ok(())
}
async fn initialize_business_config(
&self,
config: &serde_json::Value,
) -> Result<bool, StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
if inner.business_config.is_some() {
return Ok(false);
}
inner.business_config = Some(config.clone());
inner.business_revision = 1;
Ok(true)
}
async fn put_admin_session(&self, digest: &str, ttl_seconds: u64) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
inner.admin_session = Some((
digest.to_owned(),
unix_now().saturating_add(ttl_seconds as i64),
));
Ok(())
}
async fn admin_session_valid(&self, digest: &str) -> Result<bool, StateError> {
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner
.admin_session
.as_ref()
.is_some_and(|(value, expiry)| value == digest && *expiry > unix_now()))
}
async fn revoke_admin_session(&self, digest: &str) -> Result<(), StateError> {
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
if inner
.admin_session
.as_ref()
.is_some_and(|(value, _)| value == digest)
{
inner.admin_session = None;
}
Ok(())
}
async fn business_config_revision(&self) -> Result<u64, StateError> {
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
Ok(inner.business_revision)
}
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs() as i64)
}
const CONFIG_AAD: &[u8] = b"stalwart-bot/config:business/v1";
fn encrypt_config(
key: Option<&[u8; 32]>,
config: &serde_json::Value,
) -> Result<String, StateError> {
let key = key.ok_or(StateError::Encryption)?;
let unbound =
aead::UnboundKey::new(&aead::AES_256_GCM, key).map_err(|_| StateError::Encryption)?;
let sealing = aead::LessSafeKey::new(unbound);
let mut nonce = [0_u8; 12];
SystemRandom::new()
.fill(&mut nonce)
.map_err(|_| StateError::Encryption)?;
let mut body = serde_json::to_vec(config).map_err(|_| StateError::Encryption)?;
sealing
.seal_in_place_append_tag(
aead::Nonce::assume_unique_for_key(nonce),
aead::Aad::from(CONFIG_AAD),
&mut body,
)
.map_err(|_| StateError::Encryption)?;
Ok(format!("v1:{}:{}", hex_encode(&nonce), hex_encode(&body)))
}
fn decrypt_config(key: Option<&[u8; 32]>, value: &str) -> Result<serde_json::Value, StateError> {
let key = key.ok_or(StateError::Encryption)?;
let mut parts = value.split(':');
if parts.next() != Some("v1") {
return Err(StateError::Encryption);
}
let nonce = hex_decode(parts.next().ok_or(StateError::Encryption)?, 12)?;
let mut body = hex_decode(parts.next().ok_or(StateError::Encryption)?, usize::MAX)?;
if parts.next().is_some() {
return Err(StateError::Encryption);
}
let unbound =
aead::UnboundKey::new(&aead::AES_256_GCM, key).map_err(|_| StateError::Encryption)?;
let opening = aead::LessSafeKey::new(unbound);
let plain = opening
.open_in_place(
aead::Nonce::assume_unique_for_key(
nonce.try_into().map_err(|_| StateError::Encryption)?,
),
aead::Aad::from(CONFIG_AAD),
&mut body,
)
.map_err(|_| StateError::Encryption)?;
serde_json::from_slice(plain).map_err(|_| StateError::Encryption)
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn hex_decode(value: &str, max_len: usize) -> Result<Vec<u8>, StateError> {
if !value.len().is_multiple_of(2) || value.len() / 2 > max_len {
return Err(StateError::Encryption);
}
(0..value.len())
.step_by(2)
.map(|index| {
u8::from_str_radix(&value[index..index + 2], 16).map_err(|_| StateError::Encryption)
})
.collect()
}
async fn delete_key(
mut connection: redis::aio::MultiplexedConnection,
key: &str,
) -> Result<(), StateError> {
let _: i64 = redis::cmd("DEL")
.arg(key)
.query_async(&mut connection)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypted_config_roundtrip_and_tamper_fail_closed() {
let key = [7_u8; 32];
let value = serde_json::json!({"secret": "value"});
let encoded = encrypt_config(Some(&key), &value).unwrap();
assert_eq!(decrypt_config(Some(&key), &encoded).unwrap(), value);
let mut tampered = encoded.into_bytes();
let index = tampered.len() - 1;
tampered[index] = if tampered[index] == b'0' { b'1' } else { b'0' };
let tampered = String::from_utf8(tampered).unwrap();
assert!(decrypt_config(Some(&key), &tampered).is_err());
assert!(decrypt_config(Some(&[8_u8; 32]), &tampered).is_err());
assert!(encrypt_config(None, &value).is_err());
}
#[tokio::test]
async fn memory_state_is_atomic_and_supports_stream_lifecycle() {
let state = MemoryState::default();
assert!(state.claim_dedup("tg:42", 60).await.unwrap());
assert!(!state.claim_dedup("tg:42", 60).await.unwrap());
assert!(state.dedup_exists("tg:42").await.unwrap());
state.release_dedup("tg:42").await.unwrap();
assert!(!state.dedup_exists("tg:42").await.unwrap());
// A short in-flight lease can be cleared after a pre-send crash,
// while the post-send commit remains durable for ACK recovery.
assert!(state
.claim_dedup("delivery:inflight:events:1-0", 60)
.await
.unwrap());
state
.release_dedup("delivery:inflight:events:1-0")
.await
.unwrap();
assert!(!state
.dedup_exists("delivery:inflight:events:1-0")
.await
.unwrap());
assert!(state
.claim_dedup("delivery:committed:events:1-0", 604_800)
.await
.unwrap());
assert!(state
.dedup_exists("delivery:committed:events:1-0")
.await
.unwrap());
assert!(state.acquire_lock("reconcile", 30).await.unwrap());
assert!(!state.acquire_lock("reconcile", 30).await.unwrap());
assert!(state.ai_consent_until(7).await.unwrap().is_none());
state.set_ai_consent(7, 3600).await.unwrap();
assert!(state.ai_consent_until(7).await.unwrap().is_some());
state.clear_ai_consent(7).await.unwrap();
assert!(state.ai_consent_until(7).await.unwrap().is_none());
let id = state.enqueue("events", "payload").await.unwrap();
let message = StreamMessage {
id: id.clone(),
payload: "payload".into(),
};
assert!(!state
.retry_or_dlq("events", "events-dlq", "workers", &message, 2)
.await
.unwrap());
assert!(state
.retry_or_dlq("events", "events-dlq", "workers", &message, 2)
.await
.unwrap());
assert!(state
.read_batch("events", "workers", "c", 10)
.await
.unwrap()
.is_empty());
assert_eq!(
state
.read_batch("events-dlq", "workers", "c", 10)
.await
.unwrap()
.len(),
1
);
state.ack("events-dlq", "workers", &id).await.unwrap();
}
}

61
src/web.rs Normal file
View File

@ -0,0 +1,61 @@
//! Embedded configuration SPA (C-NO-LOCAL-WRITE, C-REDIS-ONLY-STATE).
//!
//! The UI is compiled into the executable so serving it creates no runtime files.
//! It sends the in-memory WORKER_TOKEN only to the same-origin `/api/config` routes;
//! the Worker forwards its Authorization header under SAF-LB-PASSTHRU, and configuration
//! persistence remains the backend's Redis responsibility.
use axum::{
body::Body,
http::{header, HeaderValue, Response, StatusCode},
routing::get,
Router,
};
const INDEX: &str = include_str!("../web/index.html");
const SCRIPT: &str = include_str!("../web/config.js");
const STYLES: &str = include_str!("../web/styles.css");
pub fn router() -> Router {
Router::new()
.route("/", get(index))
.route("/assets/config.js", get(script))
.route("/assets/styles.css", get(styles))
}
async fn index() -> Response<Body> {
static_response("text/html; charset=utf-8", INDEX, true)
}
async fn script() -> Response<Body> {
static_response("text/javascript; charset=utf-8", SCRIPT, false)
}
async fn styles() -> Response<Body> {
static_response("text/css; charset=utf-8", STYLES, false)
}
fn static_response(content_type: &'static str, body: &'static str, html: bool) -> Response<Body> {
let csp = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
let mut response = Response::new(Body::from(body));
*response.status_mut() = StatusCode::OK;
let headers = response.headers_mut();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
if html {
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(csp),
);
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
}
response
}

492
src/worker.rs Normal file
View File

@ -0,0 +1,492 @@
//! Bounded metadata notification worker (MOD-TELEGRAM-NOTIFY).
use crate::ai::LlmClient;
use crate::channel::telegram::TelegramClient;
use crate::config::BusinessConfig;
use crate::domain::jmap::{JmapBackend, JmapService};
use crate::domain::Notification;
use crate::state::ReliableState;
use async_trait::async_trait;
use std::{
future::Future,
pin::Pin,
sync::{Arc, RwLock},
};
#[async_trait]
pub trait WorkerHandler: Send + Sync {
async fn process(&self, stream: &str, payload: &str) -> Result<(), ()>;
}
/// Atomically replaceable worker handle. In-flight requests keep their old worker;
/// subsequent bounded requests observe the newest successfully-built instance.
pub struct WorkerHandle {
current: RwLock<Arc<dyn WorkerHandler>>,
}
impl WorkerHandle {
pub fn new(worker: Arc<dyn WorkerHandler>) -> Self {
Self {
current: RwLock::new(worker),
}
}
pub fn replace(&self, worker: Arc<dyn WorkerHandler>) -> Result<(), ()> {
self.current
.write()
.map(|mut current| *current = worker)
.map_err(|_| ())
}
}
/// Coordinates an all-or-nothing worker/config swap. The caller builds every client before
/// invoking `reload`; a failed factory therefore leaves both the old worker and snapshot intact.
pub struct ReloadCoordinator {
worker: Arc<WorkerHandle>,
config: RwLock<Option<BusinessConfig>>,
}
impl ReloadCoordinator {
pub fn new(worker: Arc<WorkerHandle>, config: Option<BusinessConfig>) -> Self {
Self {
worker,
config: RwLock::new(config),
}
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "stable reload API retained for coordinator tests")
)]
pub async fn reload_async<F>(&self, config: BusinessConfig, factory: F) -> Result<(), ()>
where
F: FnOnce(
&BusinessConfig,
)
-> Pin<Box<dyn Future<Output = Result<Arc<dyn WorkerHandler>, ()>> + Send>>,
{
let next = factory(&config).await?;
self.worker.replace(next)?;
*self.config.write().map_err(|_| ())? = Some(config);
Ok(())
}
/// Commit an already-built worker and its matching snapshot. Construction must happen
/// before this method so failed client creation cannot disturb the active instance.
pub fn commit(&self, config: BusinessConfig, worker: Arc<dyn WorkerHandler>) -> Result<(), ()> {
self.worker.replace(worker)?;
*self.config.write().map_err(|_| ())? = Some(config);
Ok(())
}
}
#[async_trait]
impl WorkerHandler for WorkerHandle {
async fn process(&self, stream: &str, payload: &str) -> Result<(), ()> {
let worker = self.current.read().map_err(|_| ())?.clone();
worker.process(stream, payload).await
}
}
pub struct NoopWorker;
#[async_trait]
impl WorkerHandler for NoopWorker {
async fn process(&self, _stream: &str, _payload: &str) -> Result<(), ()> {
Err(())
}
}
/// JMAP bodies are read only to obtain metadata; body text never reaches Telegram.
pub struct MetadataWorker<B> {
jmap: JmapService<B>,
telegram: Arc<TelegramClient>,
chat_id: i64,
state: Arc<dyn ReliableState>,
llm: Option<Arc<LlmClient>>,
}
impl<B: JmapBackend> MetadataWorker<B> {
pub fn new(
jmap: JmapService<B>,
telegram: TelegramClient,
chat_id: i64,
state: Arc<dyn ReliableState>,
llm: Option<Arc<LlmClient>>,
) -> Self {
Self {
jmap,
telegram: Arc::new(telegram),
chat_id,
state,
llm,
}
}
}
#[async_trait]
impl<B: JmapBackend> WorkerHandler for MetadataWorker<B> {
async fn process(&self, stream: &str, payload: &str) -> Result<(), ()> {
if stream == "stalwart:telegram" {
return self.process_telegram(payload).await;
}
if stream != "stalwart:jmap" {
return Ok(());
}
let event: WorkerEvent = serde_json::from_str(payload).map_err(|_| ())?;
let (Some(account), Some(email)) = (event.account_id.as_deref(), event.email_id.as_deref())
else {
return Err(());
};
if account != self.jmap.account_id() {
return Err(());
}
let content = self
.jmap
.read_email(email)
.await
.map_err(|_| ())?
.ok_or(())?;
let metadata = content.metadata;
self.telegram
.send_notification(
self.chat_id,
&Notification {
sender: metadata.sender.unwrap_or_else(|| "unknown".into()),
subject: metadata.subject.unwrap_or_else(|| "(no subject)".into()),
received_at: metadata
.received_at
.map(|v| v.to_string())
.unwrap_or_else(|| "unknown".into()),
},
)
.await
.map_err(|_| ())
}
}
impl<B: JmapBackend> MetadataWorker<B> {
async fn process_telegram(&self, payload: &str) -> Result<(), ()> {
let update: TelegramUpdate = serde_json::from_str(payload).map_err(|_| ())?;
let Some(message) = update.message else {
return Ok(());
};
let Some(text) = message.text.as_deref() else {
return Ok(());
};
let intent = parse_intent(text);
if matches!(&intent, Intent::Help) {
return self
.telegram
.send_text(message.chat.id, help_message())
.await
.map_err(|_| ());
}
if let Intent::Consent { ttl, label } = &intent {
if *ttl == 0 {
self.state
.clear_ai_consent(message.chat.id)
.await
.map_err(|_| ())?;
return self
.telegram
.send_text(message.chat.id, "AI摘要授权已撤销")
.await
.map_err(|_| ());
}
self.state
.set_ai_consent(message.chat.id, *ttl)
.await
.map_err(|_| ())?;
return self
.telegram
.send_text(
message.chat.id,
&format!(
"AI摘要授权已开启:{},到期时间 Unix {};仍需使用 /summary <email_id> 请求",
label,
unix_now().saturating_add(*ttl as i64)
),
)
.await
.map_err(|_| ());
}
let Some(email_id) = intent.email_id() else {
if matches!(&intent, Intent::Query) {
self.telegram
.send_text(message.chat.id, "请提供邮件 ID,例如:摘要 e-123")
.await
.map_err(|_| ())?;
}
return Ok(());
};
let content = self
.jmap
.read_email(&email_id)
.await
.map_err(|_| ())?
.ok_or(())?;
let allowed = self
.state
.ai_consent_until(message.chat.id)
.await
.map_err(|_| ())?
.is_some();
let summary = if allowed {
match &self.llm {
Some(llm) => llm
.summarize(&content.text)
.await
.unwrap_or_else(|_| fallback(&content.text)),
None => fallback(&content.text),
}
} else {
format!(
"{} — {}\n授权已到期或尚未授权;如需 AI 摘要,请重新选择授权期限。",
content.metadata.sender.unwrap_or_default(),
content.metadata.subject.unwrap_or_default()
)
};
self.telegram
.send_text(message.chat.id, &summary)
.await
.map_err(|_| ())
}
}
fn fallback(body: &str) -> String {
body.chars().take(300).collect()
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs() as i64)
}
#[derive(Debug, PartialEq, Eq)]
enum Intent {
Help,
Consent { ttl: u64, label: &'static str },
Summary(String),
Query,
Unknown,
}
impl Intent {
fn email_id(&self) -> Option<String> {
match self {
Self::Summary(id) => Some(id.clone()),
_ => None,
}
}
}
fn parse_intent(input: &str) -> Intent {
let text = input.trim().to_ascii_lowercase();
if text == "/help" || text == "help" || text.contains("帮助") || text.contains("怎么用") {
return Intent::Help;
}
if text.contains("临时") || text.contains("一次") {
return Intent::Consent {
ttl: 3600,
label: "临时1小时",
};
}
if text.contains("今天") {
return Intent::Consent {
ttl: 86_400,
label: "今天",
};
}
if text.contains("7天") {
return Intent::Consent {
ttl: 7 * 86_400,
label: "7天",
};
}
if text.contains("直到我撤销") || text.contains("长期") {
return Intent::Consent {
ttl: 365 * 86_400,
label: "直到撤销(最长365天)",
};
}
if text == "/ai on"
|| text == "/ai yes"
|| text.contains("开启 ai")
|| text.contains("同意摘要")
|| text.contains("允许 ai")
{
return Intent::Consent {
ttl: 3600,
label: "1小时",
};
}
if text == "/ai off"
|| text.contains("关闭 ai")
|| text.contains("撤销授权")
|| text.contains("停止摘要")
{
return Intent::Consent {
ttl: 0,
label: "已撤销",
};
}
let asks_summary =
text.starts_with("/summary") || text.contains("摘要") || text.contains("总结");
let asks_query =
text.contains("查询邮件") || text.contains("查看邮件") || text.contains("查邮件");
let id = text.split_whitespace().last().filter(|id| {
id.len() > 1
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
});
if asks_summary {
return id
.map(|v| Intent::Summary(v.to_owned()))
.unwrap_or(Intent::Query);
}
if asks_query {
return id
.map(|v| Intent::Summary(v.to_owned()))
.unwrap_or(Intent::Query);
}
Intent::Unknown
}
fn help_message() -> &'static str {
"使用说明:\n邮件到达后会先发送发件人、主题和时间等元数据通知。\n\n授权示例:/ai on(1小时)、临时一次、1小时、今天、7天、直到我撤销。\n/ai off:立即撤销 AI 正文授权。\n/summary <email_id>:请求指定邮件摘要;未授权或授权到期时只返回元数据并提示重新授权。\n/help(或 help):显示本说明。\n\n隐私:AI 默认关闭,只有你明确开启后才会发送正文;授权有期限且不会自动续期;正文和 AI 结果不会持久化,也不会写入日志。\n如果未配置 AI 或 AI 调用失败,将回退为本地截取摘要。"
}
#[derive(serde::Deserialize)]
struct TelegramUpdate {
#[serde(default)]
message: Option<TelegramMessage>,
}
#[derive(serde::Deserialize)]
struct TelegramMessage {
chat: TelegramChat,
#[serde(default)]
text: Option<String>,
}
#[derive(serde::Deserialize)]
struct TelegramChat {
id: i64,
}
#[cfg(test)]
mod tests {
use super::{help_message, parse_intent, Intent};
#[test]
fn help_is_safe_and_actionable() {
let text = help_message();
for command in ["/ai on", "/ai off", "/summary <email_id>", "/help"] {
assert!(text.contains(command));
}
assert!(text.contains("默认关闭"));
assert!(text.contains("不会持久化"));
assert!(!text.contains("BOT_TOKEN"));
}
#[test]
fn natural_language_intents_keep_commands_compatible() {
assert_eq!(parse_intent("请帮我看看怎么用"), Intent::Help);
assert!(matches!(
parse_intent("我同意摘要"),
Intent::Consent { ttl: 3600, .. }
));
assert!(matches!(
parse_intent("关闭 AI"),
Intent::Consent { ttl: 0, .. }
));
assert!(matches!(
parse_intent("今天允许 AI"),
Intent::Consent { ttl: 86_400, .. }
));
assert!(matches!(
parse_intent("授权 7天"),
Intent::Consent { ttl: 604_800, .. }
));
assert!(matches!(
parse_intent("直到我撤销"),
Intent::Consent {
ttl: 31_536_000,
..
}
));
assert_eq!(
parse_intent("请总结 e-123"),
Intent::Summary("e-123".into())
);
assert_eq!(parse_intent("查询邮件"), Intent::Query);
}
}
#[derive(serde::Deserialize)]
struct WorkerEvent {
#[serde(rename = "accountId")]
account_id: Option<String>,
#[serde(rename = "emailId")]
email_id: Option<String>,
}
#[cfg(test)]
mod reload_tests {
use super::*;
use secrecy::SecretString;
fn config() -> BusinessConfig {
BusinessConfig {
bot_token: SecretString::new("bot".into()),
telegram_chat_id: 1,
chat_allowlist: vec![],
telegram_webhook_secret: SecretString::new("hook".into()),
jmap_session_url: "https://mail.example.test".into(),
jmap_username: "user".into(),
jmap_password: SecretString::new("pass".into()),
jmap_push_verification: SecretString::new("verify".into()),
account_id: None,
llm_enabled: false,
llm_allow_net: false,
llm_api_key: None,
llm_base_url: None,
llm_model: None,
reconcile_token: SecretString::new("reconcile".into()),
worker_token: SecretString::new("worker".into()),
}
}
#[tokio::test]
async fn failed_build_keeps_existing_worker() {
let handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker)));
let coordinator = ReloadCoordinator::new(handle.clone(), Some(config()));
let result = coordinator
.reload_async(config(), |_| Box::pin(async { Err(()) }))
.await;
assert!(result.is_err());
assert!(handle.process("x", "y").await.is_err());
}
#[tokio::test]
async fn successful_build_replaces_worker_atomically() {
let handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker)));
let coordinator = ReloadCoordinator::new(handle.clone(), None);
let replacement: Arc<dyn WorkerHandler> = Arc::new(OkWorker);
coordinator
.reload_async(config(), move |_| {
let replacement = replacement.clone();
Box::pin(async move { Ok(replacement) })
})
.await
.unwrap();
assert!(handle.process("x", "y").await.is_ok());
}
struct OkWorker;
#[async_trait]
impl WorkerHandler for OkWorker {
async fn process(&self, _stream: &str, _payload: &str) -> Result<(), ()> {
Ok(())
}
}
}

573
web/config.js Normal file
View File

@ -0,0 +1,573 @@
(() => {
"use strict";
const RUNTIME_DEFAULTS = Object.freeze({
jmap_timeout_ms: 15000,
telegram_timeout_ms: 10000,
llm_timeout_ms: 30000,
max_retries: 3,
});
const RUNTIME_LIMITS = Object.freeze({
jmap_timeout_ms: { min: 100, max: 300000 },
telegram_timeout_ms: { min: 100, max: 300000 },
llm_timeout_ms: { min: 100, max: 300000 },
max_retries: { min: 0, max: 5 },
});
const RUNTIME_FIELDS = Object.freeze([
["jmap_timeout_ms", "jmap-timeout"],
["telegram_timeout_ms", "telegram-timeout"],
["llm_timeout_ms", "llm-timeout"],
["max_retries", "max-retries"],
]);
const I64_MIN = -(2n ** 63n);
const I64_MAX = (2n ** 63n) - 1n;
const bootstrapInput = document.querySelector("#bootstrap-input");
const authForm = document.querySelector("#auth-form");
const authCard = document.querySelector("#auth-card");
const configPanel = document.querySelector("#config-panel");
const businessForm = document.querySelector("#business-form");
const businessFields = document.querySelector("#business-fields");
const runtimeForm = document.querySelector("#runtime-form");
const runtimeFields = document.querySelector("#runtime-fields");
const connectButton = document.querySelector("#connect-button");
const reloadButton = document.querySelector("#reload-button");
const logoutButton = document.querySelector("#logout-button");
const defaultsButton = document.querySelector("#defaults-button");
const businessSaveButton = document.querySelector("#business-save-button");
const runtimeSaveButton = document.querySelector("#runtime-save-button");
const businessSaveState = document.querySelector("#business-save-state");
const runtimeSaveState = document.querySelector("#runtime-save-state");
const sessionExpiry = document.querySelector("#session-expiry");
const llmEnabled = document.querySelector("#llm-enabled");
const llmApiKey = document.querySelector("#llm-api-key");
const llmBaseUrl = document.querySelector("#llm-base-url");
const llmModel = document.querySelector("#llm-model");
const notice = document.querySelector("#notice");
// C-NO-LOCAL-WRITE / C-REDIS-ONLY-STATE: credentials and configuration stay in page memory.
let adminSession = "";
let expiryTimer = 0;
let busyAction = "";
let runtimeLoaded = false;
class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
function showNotice(kind, message) {
notice.className = `notice notice-${kind}`;
notice.textContent = message;
notice.setAttribute("role", kind === "error" ? "alert" : "status");
}
function updateButtons() {
const busy = Boolean(busyAction);
connectButton.disabled = busy;
reloadButton.disabled = busy || !adminSession;
logoutButton.disabled = busy || !adminSession;
businessSaveButton.disabled = busy || !adminSession;
runtimeSaveButton.disabled = busy || !runtimeLoaded || !adminSession;
defaultsButton.disabled = busy || !runtimeLoaded || !adminSession;
businessSaveButton.classList.toggle("is-busy", busy && busyAction === "business-save");
runtimeSaveButton.classList.toggle("is-busy", busy && busyAction === "runtime-save");
connectButton.textContent = busy && busyAction === "login" ? "正在授权…" : "创建会话";
reloadButton.textContent = busy && busyAction === "runtime-load" ? "正在读取…" : "重新读取运行参数";
businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? "正在保存并热加载…" : "保存业务配置并热加载";
runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? "正在保存…" : "保存运行参数";
}
function setBusy(action = "") {
busyAction = action;
updateButtons();
}
function parseResponseText(text, status) {
if (status === 204 || text.trim() === "") return null;
try {
return JSON.parse(text);
} catch {
throw new Error("配置服务返回了无法识别的数据格式。");
}
}
async function request(path, method = "GET", body = undefined, bearer = adminSession) {
const headers = new Headers({ Accept: "application/json" });
if (bearer) headers.set("Authorization", `Bearer ${bearer}`);
const options = {
method,
headers,
cache: "no-store",
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
};
if (body !== undefined) {
headers.set("Content-Type", "application/json");
options.body = typeof body === "string" ? body : JSON.stringify(body);
}
let response;
try {
response = await fetch(path, options);
} catch {
throw new Error("无法连接配置服务。请确认服务可用后重试。");
}
const responseText = await response.text().catch(() => "");
if (!response.ok) throw new ApiError(response.status, responseText);
return parseResponseText(responseText, response.status);
}
function showConfigView() {
authCard.hidden = true;
configPanel.hidden = false;
}
function showAuthView() {
configPanel.hidden = true;
authCard.hidden = false;
bootstrapInput.value = "";
bootstrapInput.focus();
}
function clearSecretFields() {
for (const input of document.querySelectorAll('input[type="password"]')) input.value = "";
}
function clearForms() {
businessForm.reset();
runtimeForm.reset();
runtimeLoaded = false;
runtimeFields.disabled = true;
businessFields.disabled = true;
runtimeSaveState.textContent = "读取成功后可编辑运行参数。";
businessSaveState.textContent = "完成管理会话验证后可填写完整配置。";
updateLLMRequirements();
}
function clearSession() {
adminSession = "";
if (expiryTimer) window.clearTimeout(expiryTimer);
expiryTimer = 0;
sessionExpiry.textContent = "会话仅保存在当前页面内存";
clearForms();
clearSecretFields();
setBusy("");
showAuthView();
}
function expireSession(message) {
clearSession();
showNotice("error", message);
}
function scheduleSessionExpiry(seconds) {
if (expiryTimer) window.clearTimeout(expiryTimer);
const duration = Math.max(1, Math.min(Number(seconds) || 900, 900));
sessionExpiry.textContent = `会话约 ${Math.ceil(duration / 60)} 分钟后过期;只保存在页面内存`;
expiryTimer = window.setTimeout(() => {
expireSession("管理会话已过期,已清除页面中的会话和表单内容。请重新授权。");
}, duration * 1000);
}
function normalizeRuntimeConfig(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error("配置服务返回了无法识别的运行参数。");
}
const config = {};
for (const [key] of RUNTIME_FIELDS) {
const item = value[key];
const limit = RUNTIME_LIMITS[key];
if (!Number.isInteger(item) || item < limit.min || item > limit.max) {
throw new Error("配置服务返回了超出范围的运行参数,请检查后端配置。");
}
config[key] = item;
}
return config;
}
function fillRuntimeFields(config) {
for (const [key, id] of RUNTIME_FIELDS) document.getElementById(id).value = String(config[key]);
}
function showRuntimePreview() {
fillRuntimeFields(RUNTIME_DEFAULTS);
runtimeLoaded = false;
runtimeFields.disabled = true;
runtimeSaveState.textContent = "默认值预览;读取后才能保存。";
updateButtons();
}
function activateRuntimeConfig(config) {
fillRuntimeFields(config);
runtimeLoaded = true;
runtimeFields.disabled = false;
runtimeSaveState.textContent = "已读取 Redis 中的运行参数。";
runtimeSaveState.classList.remove("is-dirty");
updateButtons();
}
function runtimeErrorMessage(error, phase) {
if (error.status === 401) return "管理会话已失效,请重新授权。";
if (error.status === 400) return "运行参数请求格式无效(HTTP 400)。";
if (error.status === 422) return "运行参数超出允许范围(超时 100–300,000 毫秒,重试 0–5 次)。";
if (error.status === 503) return phase === "save"
? "Redis 不可用或初始化尚未完成;保存状态无法确认。已禁用运行参数保存,请恢复连接后重新读取。"
: "Redis 不可用或初始化尚未完成。当前只显示默认值预览,运行参数保存已禁用;请稍后重试。";
if (error.status !== undefined) return `运行参数请求失败(HTTP ${error.status})。请稍后重试。`;
return error.message || "运行参数请求失败,请检查服务状态后重试。";
}
async function loadRuntimeConfig() {
if (!adminSession || busyAction) return;
showRuntimePreview();
setBusy("runtime-load");
showNotice("info", "正在从 Redis 读取运行参数…");
try {
const config = normalizeRuntimeConfig(await request("/api/config"));
activateRuntimeConfig(config);
showNotice("success", "运行参数已读取。业务配置仍需手动完整填写。");
} catch (error) {
if (error.status === 401) {
expireSession("管理会话已失效,页面内容已清除。请重新创建会话。");
return;
}
runtimeLoaded = false;
runtimeFields.disabled = true;
runtimeSaveState.textContent = "读取未成功,保存已禁用。";
updateButtons();
showNotice("error", runtimeErrorMessage(error, "load"));
} finally {
if (adminSession) setBusy("");
}
}
function setFieldError(input, message) {
input.setCustomValidity(message);
input.reportValidity();
input.focus();
return null;
}
function clearCustomValidity(form) {
for (const input of form.querySelectorAll("input, textarea")) input.setCustomValidity("");
}
function isBlank(value) {
return value.trim() === "";
}
function parseI64(input, label) {
const value = input.value.trim();
if (!/^-?\d+$/.test(value)) {
setFieldError(input, `${label} 必须是十进制整数。`);
throw new Error("invalid-field");
}
const parsed = BigInt(value);
if (parsed < I64_MIN || parsed > I64_MAX) {
setFieldError(input, `${label} 超出有符号 64 位整数范围。`);
throw new Error("invalid-field");
}
return parsed;
}
function parseAllowlist(input) {
const entries = input.value.split(/[\s,]+/).filter(Boolean);
if (entries.length === 0) {
setFieldError(input, "至少填写一个 chat ID。空白和逗号均可用作分隔符。");
throw new Error("invalid-field");
}
const ids = [];
const seen = new Set();
for (const entry of entries) {
if (!/^-?\d+$/.test(entry)) {
setFieldError(input, `白名单中的“${entry}”不是整数 chat ID。`);
throw new Error("invalid-field");
}
const id = BigInt(entry);
if (id < I64_MIN || id > I64_MAX) {
setFieldError(input, `白名单中的“${entry}”超出有符号 64 位整数范围。`);
throw new Error("invalid-field");
}
if (seen.has(id.toString())) {
setFieldError(input, `白名单中有重复的 chat ID:${entry}。`);
throw new Error("invalid-field");
}
seen.add(id.toString());
ids.push(id);
}
return ids;
}
function validateHttpsUrl(input, label, { noCredentials = false, noQuery = false } = {}) {
let parsed;
try {
parsed = new URL(input.value.trim());
} catch {
setFieldError(input, `${label} 必须是有效的 HTTPS URL。`);
throw new Error("invalid-field");
}
if (parsed.protocol !== "https:" || !parsed.hostname) {
setFieldError(input, `${label} 必须使用 HTTPS。`);
throw new Error("invalid-field");
}
if (noCredentials && (parsed.username || parsed.password)) {
setFieldError(input, `${label} 不能包含用户名或密码。`);
throw new Error("invalid-field");
}
const rawValue = input.value.trim();
if (noQuery && (rawValue.includes("?") || rawValue.includes("#"))) {
setFieldError(input, `${label} 不能包含查询参数或 #片段。`);
throw new Error("invalid-field");
}
return input.value.trim();
}
function requireText(id, label, secret = false) {
const input = document.getElementById(id);
const value = secret ? input.value : input.value.trim();
if (isBlank(value)) {
setFieldError(input, `${label} 不能为空。`);
throw new Error("invalid-field");
}
return value;
}
function readBusinessConfig() {
clearCustomValidity(businessForm);
const enabled = llmEnabled.checked;
llmApiKey.required = enabled;
llmBaseUrl.required = enabled;
llmModel.required = enabled;
try {
const llmBaseValue = llmBaseUrl.value.trim();
const llmModelValue = llmModel.value.trim();
const llmKeyValue = llmApiKey.value;
if (enabled && !llmBaseValue) return setFieldError(llmBaseUrl, "启用 LLM 时必须填写 Base URL。");
if (enabled && !llmModelValue) return setFieldError(llmModel, "启用 LLM 时必须填写模型名称。 ");
if (enabled && isBlank(llmKeyValue)) return setFieldError(llmApiKey, "启用 LLM 时必须填写 API key。");
const llmBase = llmBaseValue
? (enabled ? validateHttpsUrl(llmBaseUrl, "LLM Base URL") : llmBaseValue)
: null;
const jmapUrlInput = document.getElementById("jmap-session-url");
const business = {
bot_token: requireText("telegram-bot-token", "Telegram Bot Token", true),
telegram_chat_id: parseI64(document.getElementById("telegram-chat-id"), "目标 Chat ID"),
chat_allowlist: parseAllowlist(document.getElementById("chat-allowlist")),
telegram_webhook_secret: requireText("telegram-webhook-secret", "Telegram Webhook Secret", true),
jmap_session_url: validateHttpsUrl(jmapUrlInput, "JMAP Session URL", { noCredentials: true, noQuery: true }),
jmap_username: requireText("jmap-username", "邮箱用户名"),
jmap_password: requireText("jmap-password", "JMAP 应用专用密码", true),
jmap_push_verification: requireText("jmap-push-verification", "JMAP Push 验证码", true),
account_id: document.getElementById("account-id").value.trim() || null,
llm_enabled: enabled,
llm_allow_net: document.getElementById("llm-allow-net").checked,
llm_api_key: isBlank(llmKeyValue) ? null : llmKeyValue,
llm_base_url: llmBase,
llm_model: llmModelValue || null,
reconcile_token: requireText("reconcile-token", "Reconcile Token", true),
worker_token: requireText("worker-token", "Worker Token", true),
};
return business;
} catch (error) {
if (error.message === "invalid-field") return null;
throw error;
}
}
function stringifyWithBigInt(value) {
if (typeof value === "bigint") return value.toString();
if (Array.isArray(value)) return `[${value.map(stringifyWithBigInt).join(",")}]`;
if (value !== null && typeof value === "object") {
return `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}:${stringifyWithBigInt(item)}`).join(",")}}`;
}
return JSON.stringify(value);
}
function businessErrorMessage(error) {
if (error.status === 401) return "管理会话已失效。页面已清除会话和表单内容,请重新授权。";
if (error.status === 400) return "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。";
if (error.status === 422) return "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。";
if (error.status === 503) return "业务配置未获成功确认(HTTP 503)。Redis 可能尚未就绪,或后端无法建立 JMAP/LLM 客户端;页面保留本次输入供检查和重试。";
if (error.status !== undefined) return `业务配置请求失败(HTTP ${error.status})。请稍后重试。`;
return `${error.message || "业务配置请求失败。"} 请求结果可能无法确认;检查后可重新提交完整配置。`;
}
function runtimePayloadFromForm() {
clearCustomValidity(runtimeForm);
const config = {};
for (const [key, id] of RUNTIME_FIELDS) {
const input = document.getElementById(id);
const value = input.value.trim() === "" ? Number.NaN : Number(input.value);
const limit = RUNTIME_LIMITS[key];
if (!Number.isInteger(value)) return setFieldError(input, "请输入整数。"), null;
if (value < limit.min || value > limit.max) {
return setFieldError(input, `请输入 ${limit.min} 到 ${limit.max} 之间的整数。`), null;
}
config[key] = value;
}
return config;
}
function updateLLMRequirements() {
const enabled = llmEnabled.checked;
llmApiKey.required = enabled;
llmBaseUrl.required = enabled;
llmModel.required = enabled;
document.querySelector("#llm-key-required").textContent = enabled ? "必填" : "可选";
document.querySelector("#llm-url-required").textContent = enabled ? "必填" : "可选";
document.querySelector("#llm-model-required").textContent = enabled ? "必填" : "可选";
}
authForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (busyAction) return;
let bootstrapCredential = bootstrapInput.value;
bootstrapInput.value = "";
if (isBlank(bootstrapCredential)) {
showNotice("error", "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。");
bootstrapInput.focus();
return;
}
setBusy("login");
showNotice("info", "正在创建短期管理会话…");
try {
const sessionInfo = await request("/api/admin/session", "POST", undefined, bootstrapCredential);
bootstrapCredential = "";
if (!sessionInfo || typeof sessionInfo.session !== "string" || !sessionInfo.session) {
throw new Error("管理接口返回了无效的会话响应。");
}
adminSession = sessionInfo.session;
scheduleSessionExpiry(sessionInfo.expires_in);
showConfigView();
businessFields.disabled = false;
businessSaveState.textContent = "完整填写 BusinessConfigWire 后可提交。密钥不会回显。";
showRuntimePreview();
setBusy("");
showNotice("success", "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。 ");
await loadRuntimeConfig();
} catch (error) {
bootstrapCredential = "";
setBusy("");
if (error.status === 401) showNotice("error", "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。 ");
else if (error.status === 503) showNotice("error", "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。 ");
else if (error.status !== undefined) showNotice("error", `无法创建管理会话(HTTP ${error.status})。`);
else showNotice("error", error.message || "无法连接管理服务,请检查网络后重试。");
bootstrapInput.focus();
}
});
reloadButton.addEventListener("click", loadRuntimeConfig);
logoutButton.addEventListener("click", async () => {
if (!adminSession || busyAction) return;
setBusy("logout");
const session = adminSession;
try {
await request("/api/admin/session/revoke", "POST", undefined, session);
} catch {
// Local credentials are cleared even when the revocation request cannot reach Redis.
}
clearSession();
showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。");
});
defaultsButton.addEventListener("click", () => {
if (!runtimeLoaded || busyAction) return;
fillRuntimeFields(RUNTIME_DEFAULTS);
runtimeSaveState.textContent = "已恢复默认预览值;保存后才会写入 Redis。";
runtimeSaveState.classList.add("is-dirty");
showNotice("info", "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。");
});
businessForm.addEventListener("input", (event) => {
event.target.setCustomValidity("");
if (!adminSession) return;
businessSaveState.textContent = "表单包含未提交内容;每次提交会完整替换现有业务配置。";
businessSaveState.classList.add("is-dirty");
});
runtimeForm.addEventListener("input", (event) => {
event.target.setCustomValidity("");
if (!runtimeLoaded) return;
runtimeSaveState.textContent = "有尚未保存的运行参数修改。";
runtimeSaveState.classList.add("is-dirty");
});
llmEnabled.addEventListener("change", updateLLMRequirements);
businessForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!adminSession || busyAction) return;
const config = readBusinessConfig();
if (!config) return;
setBusy("business-save");
showNotice("info", "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…");
try {
await request("/api/business-config", "PUT", stringifyWithBigInt(config));
clearSecretFields();
businessSaveState.textContent = "业务配置已保存,新配置已热加载。";
businessSaveState.classList.remove("is-dirty");
showNotice("success", "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。");
updateButtons();
} catch (error) {
if (error.status === 401) {
expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。");
return;
}
businessSaveState.textContent = "未收到保存成功确认;请检查服务状态。";
showNotice("error", businessErrorMessage(error));
} finally {
if (adminSession) setBusy("");
}
});
runtimeForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!adminSession || !runtimeLoaded || busyAction) return;
const config = runtimePayloadFromForm();
if (!config) return;
setBusy("runtime-save");
showNotice("info", "正在保存运行参数到 Redis…");
try {
const saved = normalizeRuntimeConfig(await request("/api/config", "PUT", config));
activateRuntimeConfig(saved);
showNotice("success", "运行参数已保存到 Redis,并立即应用于后续请求。");
} catch (error) {
if (error.status === 401) {
expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。");
return;
}
if (error.status >= 500 || error.status === undefined) {
runtimeLoaded = false;
runtimeFields.disabled = true;
runtimeSaveState.textContent = "保存状态无法确认;重新读取成功前已禁用保存。";
updateButtons();
}
showNotice("error", runtimeErrorMessage(error, "save"));
} finally {
if (adminSession) setBusy("");
}
});
window.addEventListener("pagehide", () => {
adminSession = "";
if (expiryTimer) window.clearTimeout(expiryTimer);
clearForms();
clearSecretFields();
configPanel.hidden = true;
authCard.hidden = false;
bootstrapInput.value = "";
setBusy("");
});
showRuntimePreview();
updateLLMRequirements();
updateButtons();
})();

148
web/index.html Normal file
View File

@ -0,0 +1,148 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<title>业务配置 · Stalwart Bot</title>
<link rel="stylesheet" href="/assets/styles.css" />
<script src="/assets/config.js" defer></script>
</head>
<body>
<div class="ambient ambient-one" aria-hidden="true"></div>
<div class="ambient ambient-two" aria-hidden="true"></div>
<header class="topbar">
<a class="brand" href="/" aria-label="Stalwart Bot 配置首页">
<span class="brand-mark" aria-hidden="true">S</span>
<span class="brand-copy"><strong>stalwart bot</strong><span>业务配置</span></span>
</a>
<span class="topbar-note"><span class="secure-dot"></span>安全配置通道</span>
</header>
<main class="layout">
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow">服务管理 <span>·</span> CONFIGURATION</p>
<h1 id="page-title">管理你的服务连接</h1>
<p class="intro-copy">配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。</p>
</section>
<div id="notice" class="notice notice-info" role="status" aria-live="polite">
使用 Redis 管理凭据创建短期管理会话。
</div>
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title">
<div class="panel-heading">
<div class="icon-tile icon-violet" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
</div>
<div><p class="eyebrow">需要授权</p><h2 id="auth-title">创建管理会话</h2></div>
</div>
<p class="panel-copy">输入部署时 <code>REDIS_URL</code> 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。</p>
<form id="auth-form" class="auth-form" autocomplete="off">
<label for="bootstrap-input">Redis 管理凭据</label>
<div class="token-row">
<input id="bootstrap-input" name="bootstrap-credential" type="password" autocomplete="new-password" spellcheck="false" required placeholder="输入 Redis URL 中的 ACL 密码" aria-describedby="bootstrap-hint" />
<button id="connect-button" class="button button-primary" type="submit">创建会话</button>
</div>
<p id="bootstrap-hint" class="field-hint">不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。</p>
</form>
</section>
<section id="config-panel" class="config-panel" aria-label="业务配置与运行参数" hidden>
<div class="panel config-toolbar">
<div class="session-state"><span class="status-pulse" aria-hidden="true"></span><div><strong>管理会话有效</strong><span id="session-expiry">会话仅保存在当前页面内存</span></div></div>
<div class="toolbar-actions">
<button id="reload-button" class="button button-quiet" type="button">重新读取运行参数</button>
<button id="logout-button" class="button button-quiet" type="button">退出并清除会话</button>
</div>
</div>
<section class="panel config-section" aria-labelledby="business-title">
<div class="section-heading">
<div><p class="eyebrow">完整替换</p><h2 id="business-title">业务连接与鉴权</h2></div>
<span class="persist-label"><span class="persist-dot"></span>Redis 加密保存</span>
</div>
<div class="callout callout-warning">
<strong>业务配置不会从服务器读取。</strong>为避免密钥回显,API 不提供业务配置 GET。每次提交都必须重新填写完整配置,包括所有必填密钥;可选的 account ID 与 LLM 字段留空时会发送为 <code>null</code>。
</div>
<form id="business-form" novalidate>
<fieldset id="business-fields" disabled>
<legend class="visually-hidden">完整业务配置</legend>
<section class="form-section" aria-labelledby="jmap-title">
<div class="form-section-heading"><span class="section-number">01</span><div><h3 id="jmap-title">JMAP 邮箱</h3><p>服务端必须使用 HTTPS,URL 中不能包含凭据、查询参数或片段。</p></div></div>
<div class="field-grid">
<div class="field field-wide"><label for="jmap-session-url">JMAP Session URL <span class="required-mark">必填</span></label><input id="jmap-session-url" name="jmap_session_url" type="url" inputmode="url" autocomplete="url" required placeholder="https://mail.example.com/.well-known/jmap" /><small>HTTPS 地址,不含用户名、密码、查询参数或 #片段。</small></div>
<div class="field"><label for="jmap-username">邮箱用户名 <span class="required-mark">必填</span></label><input id="jmap-username" name="jmap_username" type="text" autocomplete="username" required /></div>
<div class="field"><label for="jmap-password">应用专用密码 <span class="required-mark">必填</span></label><input id="jmap-password" name="jmap_password" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
<div class="field"><label for="account-id">JMAP Account ID <span class="optional-mark">可选</span></label><input id="account-id" name="account_id" type="text" autocomplete="off" placeholder="留空使用 session 主账户" /></div>
<div class="field"><label for="jmap-push-verification">JMAP Push 验证码 <span class="required-mark">必填</span></label><input id="jmap-push-verification" name="jmap_push_verification" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
</div>
</section>
<section class="form-section" aria-labelledby="telegram-title">
<div class="form-section-heading"><span class="section-number">02</span><div><h3 id="telegram-title">Telegram 与访问鉴权</h3><p>仅允许白名单中的 chat ID 触发机器人操作。</p></div></div>
<div class="field-grid">
<div class="field"><label for="telegram-bot-token">Telegram Bot Token <span class="required-mark">必填</span></label><input id="telegram-bot-token" name="bot_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
<div class="field"><label for="telegram-chat-id">目标 Chat ID <span class="required-mark">必填</span></label><input id="telegram-chat-id" name="telegram_chat_id" type="text" inputmode="numeric" autocomplete="off" required placeholder="例如 -1001234567890" /><small>接受完整有符号 64 位整数。</small></div>
<div class="field field-wide"><label for="chat-allowlist">Chat allowlist <span class="required-mark">至少一项</span></label><textarea id="chat-allowlist" name="chat_allowlist" rows="3" required spellcheck="false" placeholder="每行或逗号分隔一个 chat ID"></textarea><small>整数列表;保存前会检查格式与重复项。</small></div>
<div class="field"><label for="telegram-webhook-secret">Telegram Webhook Secret <span class="required-mark">必填</span></label><input id="telegram-webhook-secret" name="telegram_webhook_secret" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
<div class="field"><label for="reconcile-token">Reconcile Bearer Token <span class="required-mark">必填</span></label><input id="reconcile-token" name="reconcile_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
<div class="field field-wide"><label for="worker-token">Worker Token <span class="required-mark">必填</span></label><input id="worker-token" name="worker_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="/worker 与兼容管理 API 使用;每次完整保存时重新输入" /><small>此值与当前管理会话不同。页面不会读取或回显已有 token。</small></div>
</div>
</section>
<section class="form-section" aria-labelledby="llm-title">
<div class="form-section-heading"><span class="section-number">03</span><div><h3 id="llm-title">LLM 服务</h3><p>启用后要求 HTTPS Base URL、模型名和 API key。</p></div></div>
<div class="toggle-grid">
<label class="toggle-card" for="llm-enabled"><input id="llm-enabled" name="llm_enabled" type="checkbox" /><span><strong>启用 LLM</strong><small>允许机器人调用兼容 OpenAI 的模型服务。</small></span></label>
<label class="toggle-card" for="llm-allow-net"><input id="llm-allow-net" name="llm_allow_net" type="checkbox" /><span><strong>允许网络调用</strong><small>关闭时不会创建 LLM 客户端。</small></span></label>
</div>
<div class="field-grid llm-fields">
<div class="field"><label for="llm-base-url">LLM Base URL <span id="llm-url-required" class="optional-mark">可选</span></label><input id="llm-base-url" name="llm_base_url" type="url" inputmode="url" autocomplete="url" placeholder="https://api.example.com/v1" /><small>启用 LLM 时必须使用 HTTPS。</small></div>
<div class="field"><label for="llm-model">模型名称 <span id="llm-model-required" class="optional-mark">可选</span></label><input id="llm-model" name="llm_model" type="text" autocomplete="off" placeholder="留空表示 null" /></div>
<div class="field field-wide"><label for="llm-api-key">LLM API Key <span id="llm-key-required" class="optional-mark">可选</span></label><input id="llm-api-key" name="llm_api_key" type="password" autocomplete="new-password" spellcheck="false" placeholder="启用 LLM 时必填;不会回显" /></div>
</div>
</section>
</fieldset>
<div class="form-footer">
<p id="business-save-state" class="save-state" aria-live="polite">完成管理会话验证后可填写完整配置。</p>
<button id="business-save-button" class="button button-primary" type="submit" disabled><span class="button-label">保存业务配置并热加载</span><span class="button-spinner" aria-hidden="true"></span></button>
</div>
</form>
</section>
<section class="panel config-section runtime-section" aria-labelledby="runtime-title">
<div class="section-heading">
<div><p class="eyebrow">出站请求</p><h2 id="runtime-title">超时与重试</h2></div>
<span class="persist-label"><span class="persist-dot"></span>Redis 保存</span>
</div>
<p class="section-copy">运行参数由 <code>GET /api/config</code> 读取、由 <code>PUT /api/config</code> 更新;新值保存后立即用于后续请求。</p>
<form id="runtime-form" novalidate>
<fieldset id="runtime-fields" disabled>
<legend class="visually-hidden">运行参数</legend>
<div class="settings-grid">
<article class="setting-card"><label for="jmap-timeout">JMAP 超时</label><p class="setting-description">单次 JMAP 请求的等待上限。</p><div class="number-control"><input id="jmap-timeout" name="jmap_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 15,000。</small></article>
<article class="setting-card"><label for="telegram-timeout">Telegram 超时</label><p class="setting-description">单次 Telegram 请求的等待上限。</p><div class="number-control"><input id="telegram-timeout" name="telegram_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 10,000。</small></article>
<article class="setting-card"><label for="llm-timeout">LLM 超时</label><p class="setting-description">单次模型请求的等待上限。</p><div class="number-control"><input id="llm-timeout" name="llm_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 30,000。</small></article>
<article class="setting-card"><label for="max-retries">最大重试次数</label><p class="setting-description">失败后额外尝试的次数,0 表示不重试。</p><div class="number-control"><input id="max-retries" name="max_retries" type="number" min="0" max="5" step="1" required /><span>次</span></div><small>0–5;默认 3。</small></article>
</div>
</fieldset>
<div class="form-footer">
<p id="runtime-save-state" class="save-state" aria-live="polite">读取成功后可编辑运行参数。</p>
<div class="form-actions"><button id="defaults-button" class="button button-quiet" type="button" disabled>恢复默认值</button><button id="runtime-save-button" class="button button-primary" type="submit" disabled><span class="button-label">保存运行参数</span><span class="button-spinner" aria-hidden="true"></span></button></div>
</div>
</form>
</section>
</section>
<aside class="privacy-note"><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
</main>
<footer class="footer"><span>STALWART BOT</span><span>配置由你的服务端管理</span></footer>
</body>
</html>

182
web/styles.css Normal file
View File

@ -0,0 +1,182 @@
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #202239;
background: #f7f7fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #202239;
--muted: #73758a;
--line: #ececf3;
--purple: #6656d8;
--purple-dark: #5544c6;
}
* { box-sizing: border-box; }
body { min-width: 320px; min-height: 100vh; margin: 0; overflow-x: hidden; background: linear-gradient(148deg, #fafaff 0%, #f7f7fb 48%, #f5f6fa 100%); }
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled, input:disabled, textarea:disabled { cursor: not-allowed; }
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid #9f93fa; outline-offset: 3px; }
[hidden] { display: none !important; }
.ambient { position: fixed; z-index: -1; pointer-events: none; border-radius: 50%; filter: blur(2px); }
.ambient-one { top: 7rem; right: -13rem; width: 31rem; height: 31rem; background: radial-gradient(circle, rgba(185, 177, 255, .2), rgba(185, 177, 255, 0) 68%); }
.ambient-two { top: 44rem; left: -17rem; width: 36rem; height: 36rem; background: radial-gradient(circle, rgba(125, 210, 196, .13), rgba(125, 210, 196, 0) 70%); }
.topbar { width: min(1100px, calc(100% - 48px)); height: 86px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(224, 224, 238, .72); }
.brand { display: inline-flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; }
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: linear-gradient(145deg, #7466e5, #5443c5); color: white; font-size: 21px; font-weight: 760; box-shadow: 0 5px 12px rgba(93, 75, 203, .2); }
.brand-copy { display: grid; gap: 1px; }
.brand-copy strong { font-size: 14px; letter-spacing: -.2px; }
.brand-copy > span { color: #85869a; font-size: 11px; letter-spacing: .05em; }
.topbar-note { display: inline-flex; align-items: center; gap: 8px; color: #77798e; font-size: 12px; }
.secure-dot, .persist-dot { width: 7px; height: 7px; border-radius: 50%; background: #48ae86; box-shadow: 0 0 0 3px rgba(72, 174, 134, .12); }
.layout { width: min(960px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 56px; }
.intro { margin: 0 0 26px; }
.eyebrow { margin: 0 0 9px; color: #817cbe; font-size: 10px; font-weight: 750; letter-spacing: .14em; text-transform: uppercase; }
.eyebrow span { padding: 0 4px; color: #c8c5e6; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 12px; font-size: clamp(29px, 5vw, 40px); line-height: 1.16; letter-spacing: -.045em; font-weight: 720; }
.intro-copy { max-width: 700px; margin-bottom: 0; color: var(--muted); font-size: 14px; line-height: 1.75; }
.notice { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 17px; padding: 14px 16px; border: 1px solid; border-radius: 11px; font-size: 13px; line-height: 1.6; }
.notice::before { width: 18px; height: 18px; flex: 0 0 18px; display: grid; place-items: center; margin-top: 1px; border-radius: 50%; font-weight: 750; }
.notice-info { color: #5d5e7b; background: #f0efff; border-color: #e3e0ff; }
.notice-info::before { content: "i"; color: white; background: #8a7de4; font-family: Georgia, serif; font-style: italic; }
.notice-success { color: #2a745b; background: #eff9f4; border-color: #d8f0e4; }
.notice-success::before { content: "✓"; color: white; background: #52ad85; font-size: 11px; }
.notice-error { color: #965345; background: #fff5f1; border-color: #f6dfd7; }
.notice-error::before { content: "!"; color: white; background: #dc806e; font-size: 12px; }
.panel { border: 1px solid rgba(228, 228, 239, .92); border-radius: 16px; background: rgba(255, 255, 255, .94); box-shadow: 0 12px 38px rgba(41, 42, 79, .055), 0 2px 5px rgba(41, 42, 79, .025); }
.auth-panel { padding: 27px 29px 25px; }
.panel-heading { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.icon-tile { width: 43px; height: 43px; display: grid; place-items: center; border-radius: 13px; }
.icon-tile svg, .privacy-icon svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.icon-violet { color: #6455ce; background: #f0efff; }
.panel-heading .eyebrow { margin-bottom: 3px; }
h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; }
.panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; }
code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
.auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; }
.token-row { display: flex; gap: 10px; }
.token-row input, .field input, .field textarea { width: 100%; min-width: 0; min-height: 42px; padding: 10px 12px; border: 1px solid #dedeea; border-radius: 8px; background: #fff; color: var(--ink); font-size: 13px; transition: border-color .15s, box-shadow .15s; }
.token-row input { height: 44px; flex: 1; }
.field textarea { min-height: 82px; resize: vertical; line-height: 1.55; }
.token-row input::placeholder, .field input::placeholder, .field textarea::placeholder { color: #a5a5b4; }
.token-row input:focus, .field input:focus, .field textarea:focus { outline: none; border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .12); }
.field-hint, .field small, .setting-card small { display: block; margin: 7px 0 0; color: #9293a4; font-size: 11px; line-height: 1.5; }
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 15px; border: 1px solid transparent; border-radius: 8px; font-size: 12px; font-weight: 680; transition: color .16s, background .16s, border-color .16s, box-shadow .16s, transform .16s; white-space: nowrap; }
.button:active:not(:disabled) { transform: translateY(1px); }
.button-primary { color: white; background: var(--purple); box-shadow: 0 4px 9px rgba(98, 81, 211, .19); }
.button-primary:hover:not(:disabled) { background: var(--purple-dark); box-shadow: 0 6px 13px rgba(98, 81, 211, .23); }
.button-primary:disabled { color: #fff; background: #b7b2d9; box-shadow: none; }
.button-quiet { color: #77788e; background: white; border-color: #e4e4ee; }
.button-quiet:hover:not(:disabled) { color: #4f48a1; background: #f8f7ff; border-color: #d8d4f2; }
.button-quiet:disabled { color: #b6b6c2; background: #fbfbfd; }
.button-spinner { display: none; width: 13px; height: 13px; border: 2px solid rgba(255, 255, 255, .4); border-top-color: white; border-radius: 50%; animation: spin .8s linear infinite; }
.is-busy .button-spinner { display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.config-panel { display: grid; gap: 16px; }
.config-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 20px; }
.session-state { display: inline-flex; align-items: center; gap: 10px; }
.status-pulse { width: 8px; height: 8px; border-radius: 50%; background: #48ad83; box-shadow: 0 0 0 4px rgba(72, 173, 131, .11); }
.session-state div { display: grid; gap: 2px; }
.session-state strong { color: #4b4d61; font-size: 11px; }
.session-state div span { color: #9999a9; font-size: 10px; }
.toolbar-actions { display: flex; gap: 7px; }
.toolbar-actions .button { min-height: 34px; padding: 0 10px; font-size: 11px; }
.config-section { overflow: hidden; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 23px 25px 17px; }
.section-heading .eyebrow { margin-bottom: 4px; }
.section-heading h2 { font-size: 20px; }
.persist-label { display: inline-flex; align-items: center; gap: 8px; padding-bottom: 3px; color: #7a7b8c; font-size: 11px; white-space: nowrap; }
.persist-dot { width: 6px; height: 6px; box-shadow: none; }
.callout { margin: 0 25px 5px; padding: 13px 15px; border: 1px solid; border-radius: 9px; font-size: 12px; line-height: 1.65; }
.callout-warning { color: #796132; background: #fffbef; border-color: #f2e9c8; }
.callout strong { color: #654d1e; }
.section-copy { margin: -3px 25px 18px; color: var(--muted); font-size: 12px; line-height: 1.65; }
fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
fieldset:disabled .field input, fieldset:disabled .field textarea { background: #fafafd; color: #858697; }
.form-section { padding: 22px 25px 24px; border-top: 1px solid #efeff5; }
.form-section-heading { display: flex; align-items: flex-start; gap: 13px; margin-bottom: 17px; }
.form-section-heading > div { min-width: 0; }
.form-section-heading p { margin-bottom: 0; color: #858697; font-size: 11px; line-height: 1.55; }
.section-number { width: 31px; height: 31px; flex: 0 0 31px; display: grid; place-items: center; border-radius: 9px; background: #f2f0ff; color: #6b5fd1; font-size: 10px; font-weight: 750; letter-spacing: .06em; }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 13px; }
.field { min-width: 0; }
.field-wide { grid-column: 1 / -1; }
.required-mark, .optional-mark { margin-left: 4px; color: #a6685f; font-size: 10px; font-weight: 600; }
.optional-mark { color: #9293a4; }
.toggle-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; margin-bottom: 17px; }
.toggle-card { min-width: 0; display: flex; align-items: flex-start; gap: 10px; padding: 13px; border: 1px solid #ececf3; border-radius: 9px; background: #fcfcfe; cursor: pointer; }
.toggle-card input { width: 16px; height: 16px; flex: 0 0 16px; margin: 2px 0 0; accent-color: var(--purple); }
.toggle-card span { display: grid; gap: 3px; }
.toggle-card strong { color: #47485f; font-size: 12px; }
.toggle-card small { color: #8a8b9c; font-size: 10px; line-height: 1.5; }
.form-footer { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 25px; border-top: 1px solid #efeff5; background: #fcfcfe; }
.save-state { margin: 0; color: #8a8b9c; font-size: 11px; line-height: 1.5; }
.save-state.is-dirty { color: #9c712e; }
.form-actions { display: flex; gap: 8px; }
.runtime-section .settings-grid { padding: 0 25px 22px; }
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.setting-card { min-width: 0; padding: 16px; border: 1px solid #eeeef4; border-radius: 11px; background: #fff; }
.setting-card > label { margin-bottom: 5px; color: #35364e; font-size: 13px; }
.setting-description { min-height: 32px; margin-bottom: 10px; color: #858697; font-size: 11px; line-height: 1.5; }
.number-control { height: 40px; display: flex; align-items: center; border: 1px solid #e2e2eb; border-radius: 7px; background: #fff; transition: border-color .15s, box-shadow .15s; }
.number-control:focus-within { border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .1); }
.number-control input { width: 100%; min-width: 0; height: 100%; padding: 0 11px; border: 0; border-radius: inherit; outline: none; background: transparent; color: #34354c; font-size: 14px; font-weight: 650; font-variant-numeric: tabular-nums; }
.number-control input:focus { box-shadow: none; }
.number-control > span { padding-right: 11px; color: #999aa9; font-size: 10px; white-space: nowrap; }
.privacy-note { display: flex; gap: 11px; align-items: flex-start; margin-top: 18px; padding: 0 4px; color: #858697; }
.privacy-icon { width: 20px; flex: 0 0 20px; color: #8b84c8; }
.privacy-icon svg { width: 18px; height: 18px; }
.privacy-note p { margin: 0; font-size: 11px; line-height: 1.65; }
.privacy-note strong { color: #64657a; font-weight: 680; }
.footer { width: min(1100px, calc(100% - 48px)); display: flex; justify-content: space-between; margin: 0 auto; padding: 18px 0 23px; border-top: 1px solid rgba(224, 224, 238, .72); color: #a5a5b2; font-size: 9px; letter-spacing: .08em; }
.footer span:last-child { letter-spacing: 0; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); border: 0; }
@media (max-width: 720px) {
.topbar { width: calc(100% - 34px); height: 72px; }
.topbar-note { font-size: 10px; }
.layout { width: calc(100% - 32px); padding-top: 40px; }
.intro { margin-bottom: 21px; }
.intro-copy { font-size: 13px; }
.auth-panel { padding: 22px 19px 20px; }
.token-row { flex-direction: column; }
.token-row .button { width: 100%; }
.config-toolbar { align-items: flex-start; padding: 13px 15px; }
.session-state div span { max-width: 155px; line-height: 1.4; }
.toolbar-actions { flex-direction: column; }
.toolbar-actions .button { min-height: 30px; }
.section-heading { align-items: flex-start; padding: 20px 17px 15px; }
.section-heading h2 { font-size: 18px; }
.persist-label { gap: 6px; font-size: 10px; }
.callout { margin: 0 17px 5px; }
.form-section { padding: 19px 17px 21px; }
.field-grid, .toggle-grid, .settings-grid { grid-template-columns: minmax(0, 1fr); }
.field-wide { grid-column: auto; }
.form-footer { align-items: flex-start; flex-direction: column; padding: 14px 17px; }
.form-footer > .button, .form-actions { width: 100%; }
.form-actions .button { flex: 1; }
.runtime-section .settings-grid { padding: 0 14px 17px; }
.section-copy { margin: -3px 17px 16px; }
.footer { width: calc(100% - 34px); }
}
@media (max-width: 420px) {
.config-toolbar { flex-direction: column; }
.toolbar-actions { width: 100%; flex-direction: row; }
.toolbar-actions .button { flex: 1; white-space: normal; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}