MessageWeave/README.zh-CN.md

324 lines
20 KiB
Markdown
Raw 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) · 架构与设计见 [docs/design.md](docs/design.md),部署与运维见 [docs/deployment.md](docs/deployment.md)
一个把 **Stalwart 邮箱**接入 **Telegram** 的个人邮件助手:新邮件来了在 Telegram 提醒你,还能按需查看/总结邮件内容。
> ⚠️ **当前仍属早期阶段,请先按本文完成小范围联调。**
> 真实邮箱和 Telegram 仍需要由部署者自行验证;JMAP Push 注册、验证回写已接入,但不会自动猜测公网 callback URL,必须由管理员显式调用注册接口。
---
## 这个项目是什么
- 你在自己的服务器上运行 [Stalwart](https://stalwart.dev) 邮箱。
- 用本工具把邮箱接到 Telegram 机器人:收到邮件时推送通知(只含发件人/主题/时间,不含正文)。
- 你可以在 Telegram 里要求查看某封邮件的原文、或让 AI 帮忙总结/翻译。
### 隐私原则
- **查看原文不会经过任何 AI**,直接读邮箱。
- **只有在你明确要求并确认后**,才把正文发给 AI 分析。
- 分析结果不会被保存。
- AI 可以完全不配置、完全关闭。
---
## 部署前,你需要准备这几样东西
1. **一个 Stalwart 邮箱账户**,并为其生成一个 **应用专用密码(App Password)**,而不是账户主密码。
2. **一个 Telegram 机器人**(通过 @BotFather 创建),拿到它的 **Bot Token**。
3. **一个 Redis 数据库**(你托管,建议开启 AOF 持久化)。机器人所有状态都放在这里。TLS Redis 使用 `rediss://`,普通 Redis 使用 `redis://`;Upstash 等服务的密码如果包含 `@`、`:`、`/` 或 `#`,必须先做 URL 编码。
4. **一个公网 HTTPS 地址**。平台会把这个地址指向机器人容器(HTTPS 由平台自动提供)。
5. **一个定时任务(外部 Cron / 调度器)**:每隔 5–10 分钟访问一次 `/reconcile` 地址,让机器人定期和邮箱对账。机器人自己不会设置定时器。
### 需要登记的地址
- 告诉 Telegram:把 webhook 指向 `你的公网地址/webhook/tg`
> 如果你使用「多实例 + Cloudflare Worker 负载均衡」(可选,见 `docs/deployment.md` §10),则把上面的 Telegram Webhook 和 Reconcile 地址指向 **Worker 的稳定域名**(`https://<your-worker>.workers.dev/webhook/tg` 等),不再指向各后端的平台地址。
---
## 在 Docker 管理器中创建容器
创建一个新容器,填好镜像、端口、内存等常规项后,只需注入 Redis 连接凭据:
| 环境变量 | 必须? | 填什么 |
|---|---|---|
| `REDIS_URL` | ✔ | Redis 连接串(含密码),例如 `redis://:密码@redis.example.com:6379/0` |
| `CONFIG_ENCRYPTION_KEY` | ✔ | 32 字节十六进制密钥;可用 `openssl rand -hex 32` 生成 |
首次启动后,通过 SPA 的受保护配置页面提交 Telegram/JMAP/LLM 和业务鉴权配置。配置密钥加密写入 Redis,API 永不回显;管理员 PUT 成功后会校验并原子热重建客户端,后续请求即时生效,失败保留旧实例。
AI 摘要默认关闭。Telegram 用户需先发送受支持的授权语,再发送 `/summary <email_id>`:`/ai on`(或 `/ai yes`,1 小时);含 `临时` 或 `一次` 的任意消息,如 `临时一次`(一次性,1 小时);`今天`(24 小时);`7天`(7 天);`直到我撤销`(或 `长期`,最长 365 天)。这些触发词按原文字匹配,需按上文的中文原文输入,不支持英文别名。撤销:`/ai off`(或 `撤销授权`)。授权到期不会自动续期。未授权时仅返回邮件元数据;未配置 AI 或调用失败则回退为本地截断摘要。
> 缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时服务进入 configuration-setup 模式,仍提供 SPA、`/api/status` 和探针;SPA 隐藏管理会话授权区,只显示缺少的环境变量与密钥不保存、不回显说明。配置恢复后 `/api/status` ready=true 才显示授权入口。
### HostStack 原生部署
HostStack 使用根目录 `hoststack.yaml` 构建并运行 Rust 服务:执行 `cargo build --release --locked`,运行 `./target/release/message-weave`,并以 `/healthz` 进行 30 秒间隔、5 秒超时的健康检查。请在 HostStack Secret 中注入 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;密钥不写入 YAML、镜像或仓库文件。
管理会话内的“启用业务处理”开关持久化在 Redis `config:enabled`;缺失或读取失败均按关闭处理。关闭时 Webhook、Reconcile 和 Worker 返回 HTTP 503,不确认或丢弃上游事件;SPA、状态和管理配置 API 仍可用。
---
## 启动后怎么看它是否正常
- 浏览器或命令行访问 **`你的公网地址/healthz`**:能返回正常状态,说明机器人进程活着。
- `/ready` 返回 JSON 就绪状态并检查配置完整性与 Redis 可访问性;200 表示基础依赖就绪,503 表示不应接收业务流量。它不会调用 JMAP、Telegram 或触发任何业务副作用。
- 运行监控建议使用 Uptime Kuma:为 `/healthz` 建立存活 HTTP 检查,为 `/ready` 建立就绪 HTTP 检查(期望 200,间隔 30 秒、超时 5 秒)。项目不引入 Prometheus 或 exporter。
- `/reconcile` 执行鉴权、Redis 单飞锁与有界 JMAP `Email/changes` 对账;事件先幂等入 Redis Streams,全部成功后才推进 Redis `sinceState`,依赖暂时失败返回 `503` 以便 Cron 重试。Push 事件仍按鉴权、去重、入队路径处理,不能将其宣传为完整实时同步。
- 冷启动基线也有页数、邮件数和时间预算;超出预算时保存断点,下一次 `/reconcile` 从断点继续,避免大邮箱反复从头扫描。
## 配置管理
打开服务根地址 **`你的公网地址/`**,输入 `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/config`、`/api/business-config`、`/api/admin/session`、`/api/admin/session/revoke` 等管理 API 路径已纳入受限路由白名单(`POST /api/push/register` 不在其中,需按 `docs/deployment.md` §10.5 直连某个后端实例)。
## 首次联调完整流程
下面是一条从空部署到 Telegram 收到第一条消息的可重复流程。所有示例中的 `<...>` 都是占位符,不要把尖括号原样复制;真实密钥不要发到聊天、工单或仓库。
### 1. 先确认部署和 Redis
访问:
```bash
curl -fsS https://<message-weave-domain>/api/status
curl -fsS https://<message-weave-domain>/healthz
```
当 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY` 正确时,`/api/status` 应包含:
```json
{"ready":true,"mode":"configured","missing":[]}
```
如果缺少变量,容器不会退出,而是进入 `configuration-setup`;SPA 只显示缺少的变量,不会显示管理会话授权区。Redis URL 必须包含正确协议:`rediss://` 表示 TLS,`redis://` 表示明文连接。TLS Redis 不能只在 `redis-cli` 命令中加 `--tls`,应用本身的 `REDIS_URL` 也必须使用 `rediss://`。
### 2. 创建 Telegram Bot 和测试群
在 Telegram 中打开 `@BotFather`,发送 `/newbot`,按提示创建机器人并保存 Bot Token。然后新建“群组”(不是频道),例如 `MessageWeave`,把机器人加入群并设置为管理员。
在群里发送 `/start`,然后临时使用 Bot API 的 `getUpdates` 查看 Chat ID:
```bash
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getUpdates"
```
返回中的:
```json
"chat":{"id":-5260770881,"title":"MessageWeave","type":"group"}
```
其中 `-5260770881` 就是 Chat ID。群 ID 通常是负数,负号不能删除。`getUpdates` 不是群组列表接口;只有机器人实际收到消息后才会返回该群。若返回空数组,先在群里发送 `/start`。若已经设置 Webhook,先删除它,否则消息会被 Webhook 消费:
```bash
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/deleteWebhook"
```
如果机器人看不到普通消息,在 `@BotFather` 执行 `/setprivacy`,选择机器人并选择 `Disable`;或者确保机器人是管理员。调试结束后再设置 Webhook。
### 3. 生成应用侧鉴权密钥
以下三个值必须分别生成,不能使用 Redis 密码、Bot Token 或 SPA 管理会话:
```bash
openssl rand -hex 32 # Telegram Webhook Secret
openssl rand -hex 32 # Reconcile Token
openssl rand -hex 32 # Worker Token
```
- **Telegram Webhook Secret**:由你生成,填写 SPA,并在 Telegram `setWebhook` 时作为 `secret_token`。MessageWeave 会校验请求头 `X-Telegram-Bot-Api-Secret-Token`。
- **Reconcile Token**:由你生成,供外部 Cron 调用 `/reconcile`。
- **Worker Token**:由你生成,供 `/worker` 和兼容管理 API 使用。
### 4. 创建 SPA 管理会话
打开 `https://<message-weave-domain>/`,输入部署时 `REDIS_URL` 中 Redis ACL 用户的密码,点击创建管理会话。会话有效期 900 秒,只保存在当前页面内存,刷新页面后需要重新授权。
### 5. 填写 Telegram 业务配置
示例:
```text
目标 Chat ID: -5260770881
Chat allowlist: -5260770881
Telegram Webhook Secret: <第一组 openssl 随机值>
Reconcile Token: <第二组 openssl 随机值>
Worker Token: <第三组 openssl 随机值>
```
`目标 Chat ID` 和 `Chat allowlist` 不代表同一个字段:前者是默认目标,后者是允许触发机器人的白名单;测试单个群时可以填写相同的 ID。allowlist 支持每行一个或逗号分隔,重复项会被拒绝。
### 6. 填写 JMAP 业务配置
- `JMAP Session URL` 可填 `https://mail.example.com` 或 `https://mail.example.com/.well-known/jmap`;两者会被归一化。
- 只能使用 HTTPS;URL 中不要放用户名、密码、query 或 fragment。
- `JMAP Username` 填邮箱账号。
- `JMAP Password` 填 Stalwart 应用专用密码。
- `Account ID` 初次联调可留空,使用 JMAP Session 的主账户。
- 不需要在 Stalwart WebUI 手工创建 PushSubscription。通常内置 `user` 角色已经包含 PushSubscription 的读取、创建、修改和删除权限;只有实际注册返回 `forbidden` 时,管理员才需要在 `/admin → Management → Directory → Accounts/Roles` 检查角色。Stalwart 的 Push 全局参数位于 `Settings → Network → JMAP → Push`,它不是具体订阅配置。
- LLM 初次联调建议关闭,避免把邮件问题和 AI 网络配置混在一起。
### 7. 注册 JMAP PushSubscription
MessageWeave 不要求你在 Stalwart WebUI 中手工创建 PushSubscription。保存 JMAP 业务配置后,使用当前 SPA 管理会话或 `WORKER_TOKEN` 调用注册接口,并明确提供公网 HTTPS callback URL:
```bash
curl -X POST "https://<messageweave-domain>/api/push/register" \
-H "Authorization: Bearer <admin-session-or-worker-token>" \
-H "Content-Type: application/json" \
-d '{"callback_url":"https://<messageweave-domain>/push/jmap"}'
```
成功返回 `push_subscription_id`。URL 必须是 HTTPS,不能包含用户名、密码或其他内嵌凭据。相同 callback URL 的重复注册请求会复用已有订阅,避免重复创建。Stalwart 随后向 `/push/jmap` 发送它生成的 `PushVerification`;MessageWeave 自动通过 JMAP `PushSubscription/set` 回写验证码,验证成功后才接收正式 Push。注册接口不会猜测平台域名,也不会接受用户填写验证码。
普通 `user` 角色通常已经具备 PushSubscription 的读取、创建、修改和删除权限,无需手工配置;只有实际返回 `forbidden` 时,管理员才检查 `/admin → Management → Directory → Accounts/Roles`。Stalwart 的 `Settings → Network → JMAP → Push` 是全局 Push 参数,不是具体订阅创建页面。
依据:[Stalwart Push notifications](https://stalw.art/docs/http/jmap/push/)、[Stalwart Permissions](https://stalw.art/docs/auth/authorization/permissions/)、[Stalwart Roles](https://stalw.art/docs/auth/authorization/roles/)、[RFC 8620 §7.2 PushSubscription](https://www.rfc-editor.org/rfc/rfc8620.html)。
保存业务配置时服务端会真实建立 JMAP 客户端;建立失败返回 `503`,不会写入坏配置,可以直接修正后再次保存。业务配置没有 GET 接口,保存成功后密钥不会回显;完整替换时必须重新输入必填项。
### 8. 设置 Telegram Webhook 并打开业务开关
业务配置保存成功后执行:
```bash
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
--data-urlencode "url=https://<message-weave-domain>/webhook/tg" \
--data-urlencode "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
```
确认返回的 `url` 正确,并且没有持续增长的 `last_error_message`。然后回到 SPA 打开“启用业务处理”。全局开关关闭时,Webhook、Reconcile 和 Worker 都返回 `503 service disabled`,不会入队,也不会 ACK 上游消息。
最后在白名单群里发送测试消息。收到消息后观察 Telegram `getWebhookInfo`、平台日志和机器人回复;如果没有回复,优先检查 Chat ID 的负号、allowlist、Webhook Secret、全局开关和机器人管理员权限。
### 9. Reconcile 鉴权测试
```bash
curl -i -X POST "https://<message-weave-domain>/reconcile" \
-H "Authorization: Bearer <RECONCILE_TOKEN>"
```
错误 Token 必须返回 `401`。外部 Cron 建议每 5–10 分钟调用一次,用于 Push 丢失时的邮件对账兜底。
## 当前邮件同步方式
当前版本不要把 PushSubscription 当作唯一可靠来源;Push 回调仍需鉴权后入队,邮件同步请同时使用外部 Cron 定时调用 `/reconcile`。对账使用 Redis 持久 `sinceState`,并在入队失败时返回 `503`,不会提前推进游标。
如果部署平台会让服务休眠,Cron 的 HTTP 请求通常可以唤醒容器,但首次请求会承担冷启动延迟,可能超出 Cron 或平台的请求超时时间。因此:
- 将 Cron 超时时间设置得足够长,并允许失败重试;
- 频率不要高于平台允许的唤醒/请求限制;
- 不要把 `/healthz` 当作邮件同步,它只检查进程存活;
- 如果平台休眠期间无法唤醒服务,Reconcile 也无法执行,此时需要关闭休眠或改用常驻实例。
对账锁会在长任务运行期间由持有者续租;续租失败不会推进游标。Push 注册和对账都使用 Redis owner-token 单飞锁,避免并发请求重复创建订阅或并行推进同一个 JMAP 游标。
## 常见踩坑与排查
### Redis 报 `can't connect with TLS, the feature is not enabled`
通常是镜像构建时 Redis TLS feature 未启用,或 `redis://` / `rediss://` 写错。使用包含 TLS 支持的最新镜像,并确保 `REDIS_URL=rediss://...`。Upstash 密码中的特殊字符必须 URL 编码,例如 `@` 写成 `%40`。
### Redis 报 `Multiplexed connection driver unexpectedly terminated`
先用同一个 URL 在外部验证网络和凭据;不要只验证 `redis-cli --tls` 而应用仍使用 `redis://`。检查 HostStack/平台是否允许出站 6379/TLS,检查 URL 中 ACL 用户、密码和端口。
### Rustls 报 provider panic
这是旧镜像同时加载多个 TLS provider 的问题。必须使用包含显式 rustls `ring` provider 安装逻辑的最新镜像,不要继续使用旧镜像标签缓存。
### `getUpdates` 返回空数组
机器人没有收到新消息,或消息已经被 Webhook 消费。删除 Webhook 后在群里重新发送 `/start`;确认机器人已加入群、是管理员,并在需要时通过 BotFather 关闭隐私模式。
### Telegram 提示无权读取消息
确认创建的是群组而不是频道;把机器人设为管理员;重新发送 `/start`。隐私模式开启时,机器人通常只能看到命令、回复和提及它的消息。
### SPA 保存业务配置返回 `503`
这通常表示 JMAP URL 无法从部署平台访问、证书/DNS 有问题、应用密码错误,或 LLM 配置不符合 HTTPS 校验。保存失败会保留旧 worker 和旧配置,不会锁死初始化槽位;修正后可重试。
### SPA 显示 configuration-setup
检查 HostStack Secret 或容器环境变量中是否同时存在:
```text
REDIS_URL
CONFIG_ENCRYPTION_KEY
```
`CONFIG_ENCRYPTION_KEY` 必须是 32 字节十六进制值,例如:
```bash
openssl rand -hex 32
```
它不能写在公开的 `hoststack.yaml`、Dockerfile、README 或镜像中。
## 隐私与运维红线(对用户透明)
- **无数据库、无本地文件**:机器人所有状态都放在你的 Redis 里;容器自身不写任何本地文件、不挂本地卷,重启后由 Redis + 邮箱对账恢复。
- **日志只走标准输出**:容器只往 stdout/stderr 打印日志,由你的平台/采集器负责收集与落盘;机器人自己**不写日志文件**。
- **日志里不会写敏感内容**:邮箱正文、密钥、AI 请求/响应、附件内容**都不会**出现在日志或 Redis 里,只记录结构化事件、计数、时间戳、脱敏摘要。
- 详见 `docs/deployment.md` §0 与 §8.2 的检查清单。
---
## 目前能做到什么 / 还差什么
**已经有了**
- 入口框架、三处安全鉴权、配置加载。
- 配置管理 SPA:使用短期 Redis admin session;可编辑完整业务配置并热加载,也可读取和保存 `/api/config` 运行参数。
- 读取 Stalwart 邮箱的基础代码(列文件夹、列邮件、读原文)。
- Telegram Webhook、Chat ID 白名单、Reconcile/Worker Bearer Token 和 Redis 持久化全局启用开关。
- HostStack 原生 Rust 部署配置与 Docker 构建配置。
- **可选**:一个免费 Cloudflare Worker 前置负载均衡(多实例 HA),见 `docs/deployment.md` §10 与 `cloudflare-worker/` 子目录。
**还差 / 尚未验证**
- ⚠️ **真实邮箱联调需要部署者验证**:JMAP 客户端代码和配置校验已提供,但不同 Stalwart 网络、权限和应用密码仍可能不同。
- **JMAP Push 注册已接入**:通过 `POST /api/push/register` 显式提交 HTTPS callback URL 后,后端调用 `PushSubscription/set create`,并由 `/push/jmap` 自动完成验证码回写。当前仍需真实 Stalwart 环境验证;外部 Cron 调用 `/reconcile` 继续作为可靠补偿通道。
- 生产级多实例协调和发送邮件能力仍未完成;AI 总结属于可选能力,需另行配置并验证。
Docker 镜像和 HostStack 原生 Rust 部署均已提供,但仍建议先按本文完成小范围联调,再考虑生产使用。
## 对开发者的门禁
任何改动发布前须全绿(在 Debian `rust:1-slim-bookworm` 容器内执行,见 `AGENTS.md` §5 与 `docs/deployment.md` §8):
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
- 真机验证用例以 `#[ignore]` 标记、由环境变量驱动(`cargo test -- --ignored jmap::`),CI 默认不跑;未实际运行前不得声称“真机通过”。
- 涉及安全边界的改动需同步 `docs/design.md`、`docs/deployment.md`、`AGENTS.md` 三份文档。
---
## 给开发/技术人员
架构、设计、部署细节见:
- [架构与设计](docs/design.md)
- [部署与运维](docs/deployment.md)
- [给 AI 编码助手的规则](AGENTS.md)
- [未完成项与阻塞原因](docs/todo.md)
## 许可证
(待定)