MessageWeave/README.zh-CN.md

289 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MessageWeave
**[English documentation / 英文文档 → README.md](README.md)**
> 一个邮箱对应一个 Telegram bot。新邮件以元数据形式送达;原文始终从你自己的 JMAP 服务器
> 只读拉取。
## 1. 是什么
MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot 之间。
- **入站。** JMAP push subscription 通知本服务"有消息变更"。静止时不做任何轮询。
- **出站。** 每个事件变成一条简短的**仅元数据** Telegram 通知:发件人、主题、时间戳。
邮件正文永不发往 Telegram。
- **按需取原文。** 你询问时,服务通过一次新的 JMAP read 调用取回原文。中间不做缓存。
- **可选摘要。** 你显式同意时,由 OpenAI-compatible LLM 返回简短摘要。请求、响应与附件
字节一律不持久化。
在这里,状态是架构边界,不是偏好选择。
- **Redis 是唯一状态存储。** 无数据库、无内嵌存储、无第二个存储。
- **进程不写任何磁盘。** 无日志文件、无数据文件、无本地卷。
- **无长连接。** 无 SSE、无 WebSocket、无长轮询。
- **日志只写 stdout。** 日志存储与保留由平台采集器负责。
- **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据
送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。它没有常驻 `server` 模式开关。不持有
EventSource/SSE 或 Telegram 长轮询连接。不向 SQLite 或本地卷持久化。也不绑定特定 serverless
平台——每条通知路径都是短请求-响应。
## 2. 两种部署形态
同一个镜像、同一个 Redis、同一套 SPA。唯一的区别是前面放什么,以及你把哪个 URL 交给 Telegram 和 Stalwart。
**Cloudflare Worker 负载均衡器是可选项。** 后端没有任何东西依赖它,有它和没有它跑起来完全一样。它存在的理由是单一的:Cloudflare 的 Load Balancer 产品在免费套餐上不可用,所以 Worker 是免费套餐账户在多个后端前面做负载均衡与故障转移的办法。
**它也不是安全层,本意不是拿来干这个的。** 没有隐藏或加固后端的意图。负载均衡器在不在,origin 都同样可以直接访问。边缘上也没有加任何访问控制。固定的路由白名单是让它在有限的一组路径上做负载均衡的手段,不是防火墙。
| | 形态 A:仅后端 origin | 形态 B:前置 Cloudflare Worker 负载均衡器 |
|---|---|---|
| 谁应答请求 | 一个后端主机 | 一个 Worker URL 对 N 个后端 origin |
| 故障转移 | 没有——origin 挂了就一直挂着 | 有界——默认 2 次尝试,只重试超时与 `5xx` |
| 你注册的回调 URL | `https://a.example` | `https://lb.example` |
| 后端可达性 | 网络上直连可达 | 同样直连可达——负载均衡器不加访问控制 |
| 负载均衡器路由集 | 不适用——没有负载均衡器 | 固定 19 个路径;未知路径返回 `404` |
| 要看哪个健康接口 | origin 上的 `GET /ready` | 透传的 `GET /ready`,外加负载均衡器聚合 `GET /healthz-worker` |
| 额外组件 | 无 | 一个 Worker 部署、一份 origin 列表 |
| 文档 | 见下文 §3 | 上文 §2、§6、`cloudflare-worker/README.zh-CN.md` |
单 origin 且没有 HA 要求时选形态 A;两个及以上 origin、或想在滚动与重建某个实例时保持一个稳定的回调 URL,就选形态 B。
切换是一次重新注册,不是迁移。注册是幂等的:注册新地址会注销旧订阅。所以从形态 A 换到形态 B(或反过来)只是 SPA 里改一个字段。不要同时注册两个 origin:注册槽只有一个,后端的平台入口不对外注册(`C-LB-SINGLE-REG-URL`)。注意这里说的是「不注册」,不是「不可达」——两种形态下 origin 都同样可以直接访问。
形态 B 里没有任何 secret 搬到负载均衡器。它不持有任何业务凭据,也不校验任何认证头。真正要求一致的是每个后端都携带同一组 `SAF-AUTH-*` secret 与同一份加密业务配置。回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致表现为随机 401,而不是路由错误。
`POST /api/bootstrap` 与 `/debug/*` 不在负载均衡器的路由集里,所以发给 Worker URL 的请求到不了它们。这是白名单在需要被负载均衡的那些路径上按设计工作。并不是在隐藏什么:两种形态下 origin 都同样可以通过自己的地址直接访问。
## 3. 从零开始
本节是手把手教程:创建 Telegram bot、部署一个后端、填写配置页、注册回调、收到第一条通知。字段语义、默认值、TTL 与错误码在 `docs/reference.md`;生产、扩展与负载均衡器运维见下文 §6。
### 3.0 你需要准备什么
- 一个你管理的 JMAP 邮箱(本指南以 Stalwart 为例),必须 HTTPS。
- 一个 Telegram 账号。
- 一个带持久化的 Redis 实例(Upstash 免费版可用;它是纯 TLS 的,URL 以 `rediss://` 开头)。
- 一台能跑容器并暴露一个 HTTP 端口的机器或平台。
- 一个能接收 webhook 的公网 HTTPS 域名——自有域名、隧道或 Cloudflare 路由都行。Telegram 与 Stalwart 都要求 HTTPS。
- 可选:OpenAI 兼容的 LLM 密钥;想要负载均衡器的话再来一个 Cloudflare 账号。
不需要别的:没有 Postgres、没有消息队列、没有 Kubernetes。
平台或反代终结 TLS。进程监听一个 HTTP 端口(`PORT`,默认 `8080`),不暴露任何其他 TCP 端口。公网 HTTPS URL 由平台提供,不是 bot 自己的。Docker 镜像用 Rust builder 与 Debian slim 运行时,外链 `rustls` 配原生根证书做出站 TLS,镜像层与构建参数里不带任何 secret。用 AOF 持久化备份 Redis——它是唯一的状态存储。
### 3.1 创建 Telegram bot
1. 在 Telegram 中打开 `@BotFather` 的对话,发送 `/newbot`。
2. 选一个显示名,再选一个以 `bot` 或 `_bot` 结尾的用户名。
3. BotFather 会回复一串 token,形如 `1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw1`。这就是 bot token。当成密码对待:拿到它的人能读取你的 webhook 流量、以你的 bot 身份发消息。一旦保存,API 不再回显它。
4. 用应当接收通知的账号或群给新 bot 发任意一条消息(例如 `/start`)。在此之前 Telegram 没有可投递的对话。
### 3.2 找到 chat id
1. 在浏览器打开 `https://api.telegram.org/bot<TOKEN>/getUpdates`,把 `<TOKEN>` 替换为第 1 步拿到的 token。
2. 在 JSON 中找 `result[].message.chat.id`。私聊是正数,例如 `123456789`;群与超级群是负数,例如 `-1001234567890`。
3. 如果是群:先把 bot 拉进群,再在群里发一条消息,然后才调 `getUpdates`。bot 在群里只有在关闭隐私模式后才能看到非命令消息:`@BotFather` → `/setprivacy` → `Disable`。
这个 id 在第 6 步要用两次:一次当通知目标,一次放进入站白名单。白名单必须至少一个 id——空名单会被判为非法配置而拒绝,不会当成「允许所有人」。
### 3.3 拿到 JMAP 凭据
1. 在 Stalwart 管理端为邮箱创建一个应用密码——是应用密码,不是账号登录密码。后端用 HTTP Basic 走 JMAP。
2. session URL 就是你的邮箱源站:`https://mail.example.com`。也可以粘贴 `https://mail.example.com/.well-known/jmap`;后端会把两者都折叠成源站,并拒绝带凭据、查询串或片段的 URL。
3. JMAP account id 是可选的。留空:首次保存成功后后端会从服务器读回,页面会显示出来。
### 3.4 跑起后端
容器只需要两个环境变量:
```sh
# .env — 不要提交这个文件
REDIS_URL=rediss://default:<password>@<host>:6379
CONFIG_ENCRYPTION_KEY=<64 位十六进制,用:openssl rand -hex 32>
```
- `REDIS_URL` 是你唯一的状态存储。像数据库一样备份它。
- `CONFIG_ENCRYPTION_KEY` 兼作 SPA 管理员凭据与已存业务密钥的加密密钥。丢了它,你既登不上配置页,也解不开已存密钥;轮换它,之前存的密钥全部不可读。
然后构建并启动:
```sh
docker build -t messageweave .
docker run --rm --env-file .env -p 8080:8080 messageweave
curl localhost:8080/healthz # → ok
```
不用 Docker 的本地构建:`cargo build --locked` 产出 `message-weave` 二进制。
`GET /healthz` 是静态存活回复,不碰 Redis。就绪报告是 `GET /ready`。在配置存在且所有已配置上游可达之前回答 503。之后回答 200 并带一份 JSON 组件报告。现在把你的 HTTPS 反代或隧道指向这个端口——第 6、7 步需要一个公网 HTTPS 域名。
如果服务有响应但什么都不做,请查看启动日志的告警:**缺少必需变量不会让进程崩溃**,它会退化为运行在内存态上的只读配置路由。见 §4。
### 3.5 登录配置页
1. 在浏览器打开页面。如果后端带着所需变量启动,第一张卡是 **Create admin session**。
2. 粘贴 `CONFIG_ENCRYPTION_KEY`——不是 Redis 密码。这会签发一个管理员会话,只活在页面内存里,有效期 30 分钟;刷新页面即登出。
### 3.6 填写业务配置
表单是整存整换,不是增量补丁:你提交的就是整份存储配置。各节:
- **01 JMAP mailbox**——session URL、用户名,以及第 3.3 步的应用密码。
- **02 Telegram and access auth**——bot token、通知 chat id、白名单,加上你自己生成的三个密钥:webhook 密钥、reconcile token 与 worker token。各自用 `openssl rand -hex 32` 生成。webhook 密钥之后要在 Telegram 侧对得上;reconcile 与 worker token 保护调度端点。
- **03 LLM service**——可选。不需要 AI 摘要就别开;一旦开启,HTTPS base URL、模型名与 API key 三者都变成必填。
- **04 Notification display**——通知里收信时间的渲染时区。
然后按顺序:
1. 点 **Test connection**。它预检凭据但不保存任何东西。
2. 点 **Save business config and hot-reload**。首次保存必须包含所有必填字段,否则服务器回答 422 且什么也不存。
3. 读按钮下方那行结果:`runtime_applied: true` 表示新运行时已在本后端装配;`false` 表示存了但运行时未装配,`warnings` 数组会指出失败的组件——最常见是 `jmap_session_url` 不可达。改字段再存一次;`/ready` 会一直 503 直到它变绿。
4. 如果保存回答 **409 conflict**,说明有人(另一个标签页、第二个运维者)在中间也存了。刷新页面拿到他们的值,再把你的改动重新套上去。
密钥是只写的:页面永不再显示已存密钥,提交空的密钥字段会保留之前的值——要清密钥就在单独一次编辑里故意留空。
如果你在负载均衡器后面跑了多个后端,**在任意一台节点上配置即可**——所有后端共享同一个 Redis,读同一份配置。完整的多实例模型见 [§6.1](#61-多实例前提)。
### 3.7 注册回调
仍在同一页,**External callbacks** 区:
1. 粘贴能到达本后端的公网 HTTPS 源站,只写 scheme + host,不带路径——例如 `https://mw.example.com`。如果你把 Cloudflare 负载均衡器放在前面,就填负载均衡器的源站,不是后端的:回调要能在后端重启后仍然可达,而负载均衡器才是那个不掉线的。
2. 点 **Register callbacks**。一次点击注册双向:Telegram webhook 注册到 `/webhook/tg`,Stalwart push 订阅注册到 `/push/jmap`。验证自动完成,页面上报结果。
注册是显式的、可重复的。注册新源站会取消上一个订阅,于是旧源站不再收到 push。失败的注册绝不会删掉旧订阅——如果连不上 Telegram,上一个 webhook 原样留着;如果连上了 Telegram 但没连上 Stalwart,页面会报部分失败并让你再点一次。
在终端验证:
```sh
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
# 期望 "url": "https://mw.example.com/webhook/tg" 且 "pending_update_count": 0
```
### 3.8 打开处理开关
把页面顶部的 **Enable business processing** 拨开。这是总开关:关着时 webhook 照收,但 `/worker` 与 `/reconcile` 拒绝排空,所以什么也到不了 Telegram。打开后,入站邮件与消息才会进队列、由 worker 排空。
### 3.9 端到端验证
1. 给自己发一封邮件。几秒内 bot 应当贴出一条通知,含发件人、主题与收信时间——绝不包含正文。这一条路径同时证明 JMAP 入站、Redis、队列与 Telegram 发送全通。
2. 在 Telegram 里试 `/search 发票`。注意:搜索触发词是中文 `搜索`、`查找`、`检索`,外加 `/search` 命令——`search invoice` 这类英文关键词不会被识别为触发。
3. 从通知里的 email id 试 `/summary <email_id>`。首次调用会请求确认;只有你确认之后、且 LLM 节开启时,摘要才会发出。
### 3.10 排程排空
Push 投递可能延迟或丢失;外部调度器是兜底。在任意一台能访问你源站的机器上,每 5–10 分钟跑一次:
```sh
scripts/cron-drain.sh --once \
--app-domain mw.example.com \
--worker-token <worker token> \
--reconcile-token <reconcile token>
```
脚本会排空队列(`/worker`)并重扫漏掉的 push(`/reconcile`);两者成功都回答 204 空 body。`MW_RECONCILE_TOKEN` 是可选的——省略时跳过 reconcile 步骤,只排空队列。如果你把负载均衡器放在前面,`/worker` 必须直达后端源站——原因见 §6.3。
### 3.11 出问题时
- **保存后什么也没到。** 查处理开关(第 3.8 步),再查 `getWebhookInfo`(第 3.7 步)。`last_error_message` 里提到 401,说明 webhook 密钥与 Telegram 发来的不一致——重存 02 节并重新注册。
- **`/ready` 回 503。** 503 的 body 只是一个错误码,不会指名是哪个组件挂了。`/ready` 回 200 时,JSON 里有逐组件布尔值(`jmap`、`telegram`、`redis`)。常见原因:`jmap_session_url` 不可达、应用密码错、或上次保存 `runtime_applied: false`。
- **表单提示冲突。** 两个会话同时存了;刷新重做(第 3.6 步)。
- **bot 在群里不理你。** 隐私模式开着;去 `@BotFather` 关掉(第 3.2 步)。
- **`/search` 回的是帮助提示。** 你的查询以拉丁词开头;触发词是 `搜索` / `查找` / `检索`。
## 4. 配置入口
共三层,彼此不可替代。
**启动期环境变量 —— 2 个必需,1 个可选。**
| 变量 | 必需 | 默认值 |
|---|---|---|
| `REDIS_URL` | 是 | — |
| `CONFIG_ENCRYPTION_KEY` | 是 | — |
| `PORT` | 否 | `8080` |
`.env.example` 只提供 2 个必需变量;其余是
文档化默认值。
**业务配置 —— 存于 Redis,由浏览器写入。**
`POST /api/bootstrap` 是一次性信任引导;`PUT /api/business-config` 可在约一秒内热更新单
项配置。正常运行中进程从不从环境变量读取业务密钥。
**健康检查。**
`GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置、Redis 与
两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由流量时,请探测 `/ready` 而不是 `/healthz`。但 `/ready` 是较重的探针(最坏约 3s)。需要保持轻量的监控在启用负载均衡器时请看负载均衡器聚合 `GET /healthz-worker`。
**远程联调面(可选,默认关闭)。**
`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条
Telegram 发送。它由双因子开关控制:进程必须带 `--debug` **且** 已设置 `DEBUG_TOKEN`。任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在负载均衡器白名单内。只能直连后端 origin 访问。运行它唯一的规则:不要打开。启动命令里不加 `--debug`,环境变量里不设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单。白名单为空时,测试发送不会受限到任何 chat。见 `docs/reference.zh-CN.md` §3 的端点契约。
## 5. 安全边界,一句话
> 所有状态都在 Redis 里;进程只读两个环境变量,不写任何磁盘。
有一条推论与部署直接相关:SPA 管理凭据——也就是 bootstrap 的信任根——就是 `CONFIG_ENCRYPTION_KEY` 本身,即启动时提供的那个 32 字节 hex 值。它只做常数时间比较,绝不回显、记录或落盘。Redis ACL 密码(如有)只认证 Redis 连接本身。它不是任何 HTTP 端点的认证凭据。
前置在后端前面的可选 Cloudflare Worker 负载均衡器不在这个边界内。它不持有任何凭据,也不校验任何认证头。负载均衡器在不在,后端都同样可以直接访问。它不会改变攻击者与你的邮件之间的任何东西。见 §2。
## 6. 运维与多实例
生产、扩展与负载均衡器运维参考。§3 的教程覆盖单实例;本节覆盖在多个后端、一个或多个 HTTPS 源站前面跑时有什么不同。
### 6.1 多实例前提
多个后端可以分担流量,因为请求状态存在 Redis、重复投递被去重、对账修复漏掉的 push 事件。不需要粘性会话,也不需要改应用代码。
所有 Redis 键都是全局命名,不带实例、主机名或源站后缀。这里共享一个 Redis 不是妥协:多个源站持有下面每一项的单一份副本。
| 数据 | 键 | 语义 |
|---|---|---|
| 业务配置 | `config:business` | 一个全局密文块(AES-256-GCM),没有按实例的槽 |
| 配置版本号 | `config:business:revision` | 一个全局单调计数器,在写入的同一 Lua 事务内自增(`CONFIG_STORE_REV_SCRIPT`) |
| 启用开关 | `config:enabled` | 一个全局布尔 |
| 对账游标 | `state:jmap:since` | 一个全局游标,没有账户维度 |
| 投递流 | `stalwart:jmap:events` / `stalwart:telegram:events` | 通过消费者组共享;组惰性创建,`XAUTOCLAIM` 回收空闲消息 |
| 对账锁 | `lock:reconcile` | 一个全局单飞锁,跨实例互斥 |
实践中两条推论最重要。
第一,**业务配置只有一份,任一源站的 SPA 编辑写入的是同一份副本。** 没有办法只改一个实例:没有任何键带实例维度,`refresh_business_config` 也不接受实例参数。这是有意行为。如果确实需要两份互相独立的配置——比如生产与一个预生产孪生——那就部署第二个实例、指向第二个 Redis,而不是在一个 Redis 内部分区。
第二,**`CONFIG_ENCRYPTION_KEY` 在每个源站上必须逐字节相同。** 它是 64 位十六进制(32 字节),直接用作 AES-256-GCM 密钥,AAD 固定为 `message-weave/config:business/v1`。它还兼作 SPA 管理员凭据。持不同值的源站既解不开别处写入的配置,也登不上 SPA。两条路径都失败关闭。
多挂源站买的是故障转移,不是吞吐。投递在共享消费者组里分散,但 `/reconcile` 被 `lock:reconcile` 全局串行化、游标是单一的。如果某个实例在批次中途死掉,下一个实例会在消息越过 `XAUTOCLAIM` 空闲窗口后把它们捡起来;代价是可能的重复投递,由 `dedup:*` 键兜底,永远不会丢消息。
### 6.2 跨源站配置收敛
一次 SPA 编辑只往 Redis 写一次。各源站**惰性收敛,在请求边界上**:每个调用 `refresh_business_config` 的入口都从 Redis 重读全局版本号,当 `remote_revision <= local_revision` 时立即返回。没有轮询循环、没有 watch,所以收不到流量的源站保持它已有的配置。
入口是 `/ready`、`/worker`、`/reconcile`、`/push/jmap`、`/webhook/tg`、`/api/config`、`/api/business-config` 与 `/api/telegram/register-webhook`。`GET /healthz` **不**在其中——它是静态 `200 "ok"`、从不刷新——`GET /api/status`、`/api/enabled`、`/api/push/register`、`/api/push/disable` 与 `/api/admin/session` 这一对也都不是。
**已知行为:一次失败的重建在下一个版本号到来前不会被重试。** 上面的版本号检查是早退,不是重试守卫。当 `build_worker` 对某个源站失败时——JMAP session URL 不可达,或 `llm_api_key` / `llm_base_url` 缺失或非法——该源站把内存中的版本号推进到远端值,并保留它已有的运行时。于是早退条件对该源站永远成立;它直到**新**版本号被写入才会再试。
同一函数内的其他失败(Redis 不可达、密文块缺失、JSON 解析失败、校验失败)不碰本地版本号,因此在下次请求时确实重试。这些路径在运行时构建之前就失败了,所以没有 worker 被重建。
症状是一个源站跑着新配置、另一个还停在旧配置——没有错误、没有日志行。失败路径静默返回。要检测它,比较各源站 `GET /debug/config` 报告的字段值——那些来自活动内存运行时。不要用它的 `revision` 字段做这件事:那个值是从 Redis 读的,显示的是最新全局版本号,即便该实例尚未收敛。
要恢复,从 SPA 再动一次配置让版本号前进;卡住的源站会再尝试一次重建。如果该源站还在 Worker 的源站列表里,在此期间先把它移除,免得请求被拆到两份配置之间。
### 6.3 可靠性与投递目标
Worker 只重试超时与后端 `5xx` 响应;`4xx` 响应直接返回。`/reconcile` 与 `/worker` 有更长的逐路由超时,且不向第二个后端故障转移,避免重复工作。后端队列处理是至少一次,依赖 Redis 支持的幂等。完整行为见路由与状态参考。
本服务允许短的投递延迟,目标至少 99.9% 的通知可用性。Redis 可用性与外部调度间隔不在应用控制之内;确切投递保证在项目章程的 `NFR-NOTIFY-SLA` 与 `NFR-RECONCILE-INTERVAL` 中描述。
## 7. 去哪读更多
| 文档 | 回答什么 |
|---|---|
| [`docs/design.zh-CN.md`](docs/design.zh-CN.md) | 为什么这样设计:数据流、JMAP 语义、Redis streams、AI 授权规则 |
| [`docs/reference.zh-CN.md`](docs/reference.zh-CN.md) | **可核对事实的唯一权威来源**——Redis 键与 TTL、错误码、路由、环境变量分层、预算常量 |
| [`docs/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 |
| [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 |
| [`docs/charter.zh-CN.md`](docs/charter.zh-CN.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 |
| [`cloudflare-worker/README.zh-CN.md`](cloudflare-worker/README.zh-CN.md) | Cloudflare Worker 负载均衡器:路由白名单、超时与重试预算、有界故障转移、健康聚合、部署 |
| [`AGENTS.md`](AGENTS.md) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 |
重要的事实都可追溯。两份文档不一致时,以 `docs/reference.md` 为准。