214 lines
9.2 KiB
Markdown
214 lines
9.2 KiB
Markdown
# HANDOFF — 邮件 Push 通知投递问题
|
||
|
||
> **日期**: 2026-10-13
|
||
> **状态**: 代码修复已完成,**未部署**
|
||
> **接手方优先级**: P0 — 用户阻塞中,邮件 push 收不到
|
||
|
||
---
|
||
|
||
## 1. 问题描述
|
||
|
||
**症状**: 用户配置了 Stalwart 邮件服务器 + Cloudflare Worker + Telegram Bot,通过 **cron 定时任务**(`/reconcile` 路径)可以正常收到邮件通知,但 **邮件 Push**(Stalwart → `/push/jmap` 路径)**完全收不到**。
|
||
|
||
**用户已确认的现象**:
|
||
- `/help` 等 Telegram 命令秒回
|
||
- cron 路径(`/reconcile` → JMAP `/changes` → worker → Telegram)工作正常
|
||
- 邮件 push 通知(Stalwart → `/push/jmap`)从未到达 Telegram
|
||
|
||
---
|
||
|
||
## 2. 根因分析(已完成,结论确认)
|
||
|
||
### 2.1 根因: 验证码 key 5 分钟后过期(旧代码 `TTL=300`)
|
||
|
||
Stalwart 按 RFC 8620 协议向我们注册 Push 订阅时:
|
||
|
||
1. **注册时**: Stalwart 发第一次带 `verificationCode` 的验证请求,我们的后端把验证码存进 Redis(key = `push:subscription:{id}`,value = 验证码)。**旧代码 TTL = 300 秒(5 分钟)**。
|
||
2. **事件推送时**: 每封新邮件到达后,Stalwart 发带 `emailId`/`changeType` 但**不带** `verificationCode` 的事件推送。我们的后端去 Redis 查 `push:subscription:{id}` 确认订阅已验证 — 但 key 已在 5 分钟后过期 → 返回 **401 unauthorized** → 邮件通知被丢弃。
|
||
|
||
### 2.2 时间线证据(来自 Stalwart 日志)
|
||
|
||
| 时间 (UTC) | 事件 |
|
||
|---|---|
|
||
| 02:52:30 | 用户执行 register,Stalwart 分配 `push_subscription_id: "b"`,向 `/push/jmap` 发第一次验证请求,后端存 key(TTL=300) |
|
||
| ~02:57:30 | Redis key 过期被自动删除 |
|
||
| 02:58:49 | 测试邮件到达 Stalwart,changeId=305,documentId=167 |
|
||
| 02:58:50 | Stalwart 向 `/push/jmap` 推送事件 → 后端查 key → **401 unauthorized** |
|
||
|
||
**注册到 401 恰好间隔 ~6 分钟,与 TTL=300(5 分钟)完全吻合。**
|
||
|
||
### 2.3 为什么 cron 能收到
|
||
|
||
cron 走 `/reconcile` → 主动调 JMAP `/changes` → 拉新邮件 → 发 Telegram。这条路径**完全不依赖验证码 key**,所以不受 TTL 影响。push 路径依赖 key 核对,key 过期就 401。
|
||
|
||
### 2.4 次要问题: 限流器 429
|
||
|
||
注册日志里出现 `429 push_verify_rate_limited` — Stalwart 在 30 秒内重复发了验证请求,第二个被限流器(`ratelimit:push-verify:{subscription}`,TTL=30s)拦住。但**第一个请求已成功存了 key**(只是 5 分钟后过期),所以 429 不是当前 401 的原因,只是鲁棒性问题。
|
||
|
||
---
|
||
|
||
## 3. 已完成的修复(3 个 commit,均已 push 双 remote)
|
||
|
||
| Commit | 修复内容 | 关键代码位置 |
|
||
|---|---|---|
|
||
| `d452f95` | `PUSH_SUBSCRIPTION_SECONDS` 300 → 604800(7 天) | `src/state.rs` ttl 模块 |
|
||
| `7331cb7` | `PUSH_SUBSCRIPTION_SECONDS` 604800 → **0(永不过期)** | `src/state.rs` ttl 模块 |
|
||
| `f9160c0` | ① 验证响应回 `{"verificationCode":"..."}`(原返回空 204,违反 RFC 8620 §7.2);② `verify_push_subscription` JMAP API 改为 best-effort(失败不阻塞) | `src/notify.rs` `jmap_push` 验证分支 |
|
||
|
||
### 3.1 关键代码变化
|
||
|
||
**`src/state.rs`** — TTL 常量:
|
||
```rust
|
||
pub const PUSH_SUBSCRIPTION_SECONDS: u64 = 0; // 永不过期
|
||
```
|
||
`remember_push_subscription` 处理 `ttl_seconds == 0` 时用 `SET key value`(不带 `EX`),MemoryState 用 `expiry = i64::MAX`。
|
||
|
||
**`src/notify.rs`** — 验证流程(当前代码,已修但未部署):
|
||
```
|
||
if 带 verificationCode(验证请求):
|
||
1. rate limiter claim_dedup → 429 拦截(❗这在 remember 之前,有问题)
|
||
2. verify_push_subscription JMAP API → best-effort(warn 不阻塞)
|
||
3. remember_push_subscription → 存 key(TTL=0 永不过期)
|
||
4. set_push_subscription_status → "verified"
|
||
else(事件推送):
|
||
查 push:subscription:{id} 是否存在 → 不存在 401
|
||
```
|
||
|
||
**RFC 8620 §7.2 响应**(`notify.rs` 的 `jmap_push` handler,验证请求分支末尾):
|
||
```rust
|
||
if let Some(code) = push.verification_code.as_deref() {
|
||
return axum::Json(serde_json::json!({
|
||
"verificationCode": code
|
||
})).into_response();
|
||
}
|
||
return StatusCode::NO_CONTENT.into_response();
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 未完成的工作(接手方要做)
|
||
|
||
### 4.1 【P0】部署最新代码到服务器
|
||
|
||
**当前服务器跑的是旧代码(TTL=300),三个修复 commit 都在 git 里但没部署。**
|
||
|
||
部署方式(Docker):
|
||
```bash
|
||
# 在服务器上
|
||
git pull origin main # 拉到 f9160c0 或更新
|
||
docker build -t messageweave .
|
||
docker stop <容器名>
|
||
docker run --rm --env-file .env -p 8080:8080 messageweave
|
||
```
|
||
|
||
或者如果不用 Docker:
|
||
```bash
|
||
git pull origin main
|
||
cargo build --locked --release
|
||
# 重启运行中的 message-weave 进程
|
||
```
|
||
|
||
### 4.2 【P0】部署后重新注册 Push 订阅
|
||
|
||
部署新代码后,旧的 key 可能还残留(或已过期),需要让 Stalwart 重新走一次验证流程:
|
||
|
||
```bash
|
||
# 变量
|
||
BACKEND="https://messageweave.motofans.club"
|
||
TOKEN="你的 worker_token(业务配置里的)"
|
||
|
||
# 1. 禁用旧订阅
|
||
curl -X POST "$BACKEND/api/push/disable" \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"callback_url":"https://messageweave.motofans.club/push/jmap"}'
|
||
|
||
# 2. 重新注册(Stalwart 会发新验证请求,新代码存 key 且永不过期)
|
||
curl -X POST "$BACKEND/api/push/register" \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"callback_url":"https://messageweave.motofans.club/push/jmap"}'
|
||
|
||
# 3. 发测试邮件,观察 Stalwart 日志确认不再 401
|
||
```
|
||
|
||
### 4.3 【P1】修复限流器顺序(代码改动)
|
||
|
||
**问题**: `notify.rs` `jmap_push` 的验证分支中,rate limiter(`claim_dedup("ratelimit:push-verify:{subscription}")`)在 `remember_push_subscription` **之前**。如果 Stalwart 在 30 秒内重发验证请求,第二个请求被 429 拦住 → `remember_push_subscription` 不会被调用 → 那次尝试的 key 没存。虽然第一个请求通常已存了 key,但这不鲁棒。
|
||
|
||
**修法**: 把 `remember_push_subscription` 移到 rate limiter **之前**,rate limiter 只控制**出站** `verify_push_subscription` JMAP API 调用(防止频繁调外部 API),**不拦验证请求本身**。
|
||
|
||
修改后流程应该是:
|
||
```
|
||
if 带 verificationCode:
|
||
1. remember_push_subscription(先存 key,TTL=0) ← 移到最前
|
||
2. set_push_subscription_status → "verified"
|
||
3. rate limiter claim_dedup → 只拦 JMAP API 调用,不拦整个请求
|
||
4. verify_push_subscription JMAP API → best-effort
|
||
```
|
||
|
||
**需要同步更新的文档**(改代码后必须一起改,否则 GATE-DOCS 会挂):
|
||
- `docs/reference.md` / `docs/reference.zh-CN.md` — `ratelimit:push-verify:{subscription}` 键说明行 + 429 `push_verify_rate_limited` 错误码表行
|
||
- `docs/opengaps.md` / `docs/opengaps.zh-CN.md` — TTL 表 `PUSH_VERIFY_LIMIT_SECONDS` 行
|
||
- `docs/design.md` / `docs/design.zh-CN.md` — "Push verification code writing current limiting" 段落
|
||
|
||
### 4.4 【P2】验证完整链路
|
||
|
||
部署 + 重新注册后:
|
||
1. 发测试邮件
|
||
2. 看 Stalwart 日志: 验证请求应该返回 200 + `{"verificationCode":"..."}`,事件推送应该返回 2xx(不再 401)
|
||
3. 看 Telegram 是否收到通知
|
||
4. 等 10+ 分钟再发第二封(确认 key 不再过期)
|
||
|
||
---
|
||
|
||
## 5. 两条投递路径说明
|
||
|
||
| 路径 | 触发方式 | 依赖验证码 key | 当前状态 |
|
||
|---|---|---|---|
|
||
| **Cron** | 定时调 `/reconcile` → JMAP `/changes` → 拉新邮件 → worker → Telegram | ❌ 不依赖 | ✅ 正常工作 |
|
||
| **Push** | Stalwart → `/push/jmap`(事件推送)→ 查 key → worker → Telegram | ✅ 依赖(401 的原因) | ❌ 旧代码 key 过期 → 401 |
|
||
|
||
---
|
||
|
||
## 6. 环境信息
|
||
|
||
- **Stalwart**: `0.16.26`,Docker 运行在 `mail.motofans.club`
|
||
- **Push 订阅 ID**: `"b"`(Stalwart 分配的单字符 ID)
|
||
- **回调 URL**: `https://messageweave.motofans.club/push/jmap`
|
||
- **后端仓库**: `/home/okabe/Repo/messageweave`
|
||
- **双 remote**: `origin`(motofans.club)+ `upstream`(GitHub fork)
|
||
- **测试基线**: Rust 105 passed / 0 failed / 4 ignored,GATE-DOCS 6/6 PASS
|
||
- **最新 commit**: `f9160c0`(已 push 双 remote)
|
||
|
||
---
|
||
|
||
## 7. 已知的文档残留(非阻塞)
|
||
|
||
- `docs/opengaps.md` 声称"当前无未完成项",但限流器顺序问题(§4.3)还没修 — 接手方修完后应更新
|
||
- `docs/reference.md` / `docs/reference.zh-CN.md` 的 `ratelimit:push-verify` 与 429 错误码表行,在限流器顺序修完后需要改
|
||
- `docs/roadmap.md` 不存在(之前有任务提到要创建,没落地)
|
||
- `audit_anchor_semantics.py` 有 7 个候选(历史遗留,非本轮引入)
|
||
- `docs/reference.zh-CN.md` 机翻质量问题(历史遗留)
|
||
|
||
---
|
||
|
||
## 8. 快速诊断命令
|
||
|
||
```bash
|
||
# 查看部署版本
|
||
curl -s https://messageweave.motofans.club/api/status | jq .
|
||
|
||
# 查看 push 注册状态(需要 worker_token)
|
||
TOKEN="你的token"
|
||
curl -s https://messageweave.motofans.club/api/push/status \
|
||
-H "Authorization: Bearer $TOKEN" | jq .
|
||
|
||
# 查看 Redis 中验证码 key 是否存在(需要 Redis 访问)
|
||
# key 格式: push:subscription:{subscription_id}
|
||
redis-cli GET "push:subscription:b"
|
||
|
||
# 查看 Stalwart 日志(在 mail.motofans.club 上)
|
||
docker logs <stalwart容器名> --since 10m 2>&1 | grep -i push
|
||
```
|