diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index df3319c..b02892e 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -5,6 +5,7 @@ GitHub Actions 工作流目录。
## ✅ 放什么
- CI 验证
- 构建检查
+- `e2e.yml` 分别运行快速页面 smoke 与真实本地 Worker/D1/R2 业务回归,并上传浏览器和 Worker 证据
- 发布自动化
## 🚫 不放什么
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 86670eb..8b5c0a2 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -52,3 +52,42 @@ jobs:
path: apps/web/test-results
if-no-files-found: ignore
retention-days: 7
+
+ business-playwright:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
+ with:
+ version: 10.18.2
+
+ - name: Setup Node
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install workspace dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Playwright Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Run isolated Worker and D1 business E2E
+ run: pnpm test:e2e:business
+
+ - name: Upload business E2E evidence
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: business-playwright-evidence
+ path: |
+ apps/web/playwright-report/business
+ apps/web/test-results/business
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddb78d8..cc5b8e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- Preserved canonical notification events independently from chat-platform request bodies so failed Slack, Discord, Feishu, and WeCom deliveries replay with complete mail and extraction content; added bounded response reading, request timeouts, platform acknowledgement validation, and explicit errors for legacy chat records without replay data (D1 migration 0024).
+- Brought the local database schema snapshot up to date with inbound Message-ID, notification channels, and retention/idempotency indexes; added empty-database and existing-delivery migration regression coverage.
+- Restored background accessibility during the same render commit that closes a modal, and cleaned up API key clipboard feedback timers on unmount so route transitions do not leave the page inert or trigger delayed updates.
+
+### Added
+
+- Added owner-scoped notification status and replay controls in Webhook/Telegram, administrator notification aggregates, explainable rule dry-runs, and explicit IANA quiet-hours timezones while preserving UTC for existing rules (D1 migration 0026).
+- Added durable inbound notification tasks committed with mail metadata, conditional lease-based claiming, bounded exponential retries with Retry-After, stable event IDs, owner-scoped replay, and expiration/deletion cleanup (D1 migration 0025). Minute-level notification recovery runs independently from hourly mail retention cleanup.
+- Added isolated real Worker/D1/R2 business E2E with browser registration and mailbox creation, inbound extraction, API key lifecycle, permissions, quotas, Message-ID deduplication, HTML attachments, webhook replay, and controlled outbound retries; added a separate CI job and staging external-service validation checklist.
+- Added a next-development roadmap grounded in the current 0.4.0 implementation, prioritizing chat-channel replay fixes, real Worker/D1 business E2E, recoverable notification delivery, outbound status handling, extraction quality evaluation, and first-use guidance with explicit acceptance criteria.
+
+### Changed
+
+- Normalized roadmap and validation-report formatting so the committed documents pass whitespace validation.
+- Corrected implementation-versus-verification documentation, backup/restore binding instructions, test coverage descriptions, and unmeasured deployment-time claims on the landing page.
+- Rebuilt the landing hero around demonstrating the product instead of describing it: an auto-playing inbox demo (mail arrives → verification code extracted → copied), an interactive extraction playground running the real Worker-side extraction engine client-side on pasted text, open-source/self-host/edge positioning pills, honest CTAs (申请邀请 + 自托管部署 instead of 立即开始 hitting the invite wall), and the rotating headline word now carries the brand accent.
+- Landing content honesty: key metrics describe automated tests, separate staging/production environments, Cloudflare edge deployment, and the open-source license; the testimonials section is labeled 场景示例 with workflow scenarios instead of fabricated personas.
+- Added a blanket prefers-reduced-motion guard covering all landing-page animations.
+
## [0.4.0] - 2026-09-06
### Added
diff --git a/README.md b/README.md
index 8037851..1d918c7 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -21,6 +21,18 @@
`WeMail` 是一个基于 pnpm monorepo 的 disposable email 服务,提供临时邮箱、邮件收取、发件控制、API Key、公告、Webhook、Telegram 通知与管理员后台能力。
+项目版本以根 [`package.json`](package.json) 为准;切版时使用 `pnpm version:sync` 同步 workspace、共享版本常量与 OpenAPI,并更新本页徽章。
+
+## 能力与验证范围
+
+| 状态 | 说明 | 当前范围 |
+| --- | --- | --- |
+| 已实现 | 源码中存在接口、业务逻辑或界面 | 收件、提取、通知规则、聊天渠道、发件、权限与管理员设置 |
+| 已联调 | 指定环境中的组件协同有可复现测试结果 | `pnpm test:e2e:business` 连接真实本地 Worker/D1/R2 与浏览器,外部供应商为受控适配器;快速 smoke 保留模拟 API,范围见[测试策略](docs/testing-strategy.md) |
+| 已上线验收 | 指定 commit、环境与真实服务有验收记录 | 需要按[发布手册](docs/deploy-runbook.md)保存真实 Email Routing、通知平台、Resend 与权限验证证据;本页不代表所有外部链路均已验收 |
+
+`GET /api/users/commercial` 当前返回部署级用量、套餐展示和一个 `default` 组织汇总;独立团队、成员关系与共享邮箱授权仍需后续开发。系统设置中的历史「运维中心」「成熟度总览」「可靠性后台」面板已移除,`/system/operations` 转到系统设置;相关管理员 API 保留。后续交付顺序见[开发计划](docs/plans/2026-10-03-next-development-roadmap.md)。
+
## 仓库结构
```text
@@ -100,6 +112,7 @@ pnpm test:shared
pnpm test:worker:integration
pnpm test:web:integration
pnpm test:e2e
+pnpm test:e2e:business
```
首次运行 E2E 前安装浏览器:
@@ -331,6 +344,8 @@ curl -i "$PRODUCTION_API_BASE_URL/api/system/health"
只在 GitHub Actions 不可用或明确需要手动操作时使用。
+Actions 的绑定注入不会在本地自动发生。手动部署前按[备份恢复手册的私有配置步骤](docs/backup-restore-runbook.md#22-准备私有-wrangler-配置)准备 `.wrangler/wrangler.recovery.toml`,填入目标环境的真实 D1/KV ID,并核对路由、R2 bucket、Cookie 与 CORS。公开 `wrangler.toml` 继续保留占位值。构建前按目标环境设置 `VITE_API_BASE_URL`;只有已配置同域 `/api/*` Worker 路由时才可省略。
+
staging:
```bash
@@ -340,8 +355,8 @@ pnpm typecheck
pnpm lint
pnpm build
cd apps/worker
-pnpm exec wrangler d1 migrations apply wemail-staging --env staging --remote
-pnpm exec wrangler deploy --env staging
+pnpm exec wrangler d1 migrations apply DB --env staging --remote --config .wrangler/wrangler.recovery.toml
+pnpm exec wrangler deploy --env staging --config .wrangler/wrangler.recovery.toml
cd ../..
pnpm exec wrangler pages deploy apps/web/dist --project-name= --branch=staging
```
@@ -355,8 +370,8 @@ pnpm typecheck
pnpm lint
pnpm build
cd apps/worker
-pnpm exec wrangler d1 migrations apply wemail-production --env production --remote
-pnpm exec wrangler deploy --env production
+pnpm exec wrangler d1 migrations apply DB --env production --remote --config .wrangler/wrangler.recovery.toml
+pnpm exec wrangler deploy --env production --config .wrangler/wrangler.recovery.toml
cd ../..
pnpm exec wrangler pages deploy apps/web/dist --project-name=
```
@@ -395,4 +410,4 @@ D1 migration 如果已经写入生产,不能盲目回滚代码后假设数据
## 致谢
-
\ No newline at end of file
+
diff --git a/apps/web/e2e-business/README.md b/apps/web/e2e-business/README.md
new file mode 100644
index 0000000..97bb395
--- /dev/null
+++ b/apps/web/e2e-business/README.md
@@ -0,0 +1,7 @@
+# 真实本地业务回归
+
+运行 `pnpm test:e2e:business`,无需手动启动服务。测试实际连接生产路由、D1 本地数据库、R2 本地附件存储与浏览器页面,没有 `page.route()` API 替身。外部供应商使用 Worker 测试入口里的受控适配器。
+
+`BUSINESS_TEST_TOKEN` 由 runner 临时生成;邀请码、用户、邮箱和消息都在隔离数据库中。测试结束时删除临时配置与存储,日志/失败截图/trace 保存在已忽略的 `apps/web/test-results/business` 中。
+
+Browser 插件与其 browser skill 未在当前会话中提供,本批次按前端测试技能使用项目既有 Playwright。验证目标为“真实邀请码注册 → 建邮箱 → 收件与多值提取 → API Key 与权限”,同时断言无页面运行时异常并保存收件页截图。
diff --git a/apps/web/e2e-business/mail-workflow.spec.ts b/apps/web/e2e-business/mail-workflow.spec.ts
new file mode 100644
index 0000000..4d30b3c
--- /dev/null
+++ b/apps/web/e2e-business/mail-workflow.spec.ts
@@ -0,0 +1,238 @@
+import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
+
+const apiUrl = process.env.BUSINESS_API_URL ?? "http://127.0.0.1:8789";
+const testHeaders = { authorization: `Bearer ${process.env.BUSINESS_TEST_TOKEN}` };
+const password = "business-password-123";
+
+async function driver(request: APIRequestContext, path: string, data: unknown) {
+ const response = await request.post(`${apiUrl}/__test/${path}`, { headers: testHeaders, data });
+ expect(response.ok(), await response.text()).toBe(true);
+ return response.json();
+}
+
+async function register(request: APIRequestContext, index: number) {
+ const email = `business-member-${index}@example.com`;
+ const response = await request.post(`${apiUrl}/api/auth/register`, {
+ data: { email, password, name: `Member ${index}`, inviteCode: `BUSINESS-${index}` }
+ });
+ expect(response.status(), await response.text()).toBe(201);
+ return email;
+}
+
+async function createMailbox(request: APIRequestContext, label: string) {
+ const response = await request.post(`${apiUrl}/api/accounts`, { data: { label, domain: "example.com" } });
+ expect(response.status(), await response.text()).toBe(201);
+ return (await response.json()).mailbox as { id: string; address: string };
+}
+
+async function receive(request: APIRequestContext, mailbox: { address: string }, id: string, body = "验证码 654321,10 分钟内有效。Verify your login: https://example.net/verify/token") {
+ return driver(request, "inbound", {
+ to: mailbox.address,
+ raw: ["From: Security ", `To: ${mailbox.address}`, `Message-ID: <${id}@example.net>`, "Subject: Business verification", "Content-Type: text/plain; charset=utf-8", "", body].join("\r\n")
+ });
+}
+
+async function createKey(request: APIRequestContext, scopes: string[]) {
+ const response = await request.post(`${apiUrl}/api/api-keys`, { data: { label: "Business integration", scopes } });
+ expect(response.status(), await response.text()).toBe(201);
+ return (await response.json()).key as { id: string; secret: string };
+}
+
+async function registerInBrowser(page: Page) {
+ await page.goto("/register?next=/accounts/list");
+ await page.getByRole("textbox", { name: "用户名", exact: true }).fill("Business Member");
+ await page.getByRole("textbox", { name: "邮箱", exact: true }).fill("business-member-1@example.com");
+ await page.locator("#register-password").fill(password);
+ await page.getByRole("textbox", { name: "邀请码", exact: true }).fill("BUSINESS-1");
+ await page.getByRole("button", { name: "立即注册", exact: true }).click();
+ await expect(page).toHaveURL(/\/accounts\/list$/);
+}
+
+test("registers a member, creates a mailbox in the browser, reads real inbound mail and revokes an API key", async ({ page, playwright }, testInfo) => {
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
+ let expectedAuthRejections = 0;
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
+ page.on("response", (response) => { if (response.status() === 401 && new URL(response.url()).pathname === "/api/auth/session") expectedAuthRejections += 1; });
+ await registerInBrowser(page);
+ await expect(page.getByRole("button", { name: "新建账号", exact: true })).toBeVisible();
+ await page.context().clearCookies();
+ await page.goto("/login?next=/accounts/list");
+ await page.getByRole("textbox", { name: "邮箱", exact: true }).fill("business-member-1@example.com");
+ await page.locator("#login-password").fill(password);
+ await page.getByRole("button", { name: "立即登录", exact: true }).click();
+ await expect(page).toHaveURL(/\/accounts\/list$/);
+ await expect(page.getByRole("button", { name: "新建账号", exact: true })).toBeVisible();
+ const sessionResponse = await page.evaluate(async (url) => {
+ const response = await fetch(`${url}/api/auth/session`, { credentials: "include" });
+ return { status: response.status, body: await response.json() };
+ }, apiUrl);
+ expect(sessionResponse.status).toBe(200);
+ const session = sessionResponse.body;
+ expect(session.user.role).toBe("member");
+ await page.getByRole("button", { name: "新建账号", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "创建新账号" });
+ await dialog.getByLabel("账号标签", { exact: true }).fill("Business Inbox");
+ await dialog.getByRole("combobox", { name: "邮箱域名", exact: true }).click();
+ await page.getByRole("option", { name: "example.com", exact: true }).click();
+ const creation = page.waitForResponse((response) => response.url() === `${apiUrl}/api/accounts` && response.request().method() === "POST");
+ await dialog.getByRole("button", { name: "创建账号", exact: true }).click();
+ const createdResponse = await creation;
+ expect(createdResponse.status()).toBe(201);
+ const mailbox = (await createdResponse.json()).mailbox;
+ await expect(dialog).not.toBeVisible();
+ await expect(page.getByRole("row").filter({ hasText: mailbox.address })).toBeVisible();
+ const browserApi = await playwright.request.newContext({ storageState: await page.context().storageState() });
+ const mailboxes = await (await browserApi.get(`${apiUrl}/api/accounts`)).json();
+ expect(mailboxes.mailboxes.map((item: { id: string }) => item.id)).toContain(mailbox.id);
+ const received = await receive(page.request, mailbox, "browser-first");
+ await page.goto("/mail/list");
+ await expect(page).toHaveTitle(/邮件列表.*WeMail/);
+ await expect(page.getByText("Business verification", { exact: true }).first()).toBeVisible();
+ await page.getByText("Business verification", { exact: true }).first().click();
+ await expect(page.getByRole("region", { name: "邮件识别结果" }).or(page.locator('[aria-label="邮件识别结果"]'))).toContainText("654321");
+ await expect(page.locator('[aria-label="其他提取结果"]')).toContainText("https://example.net/verify/token");
+ await expect(page.locator('[aria-label="阅读与提取详情"]')).toContainText("10 分钟内有效");
+ const screenshot = testInfo.outputPath("inbox.png");
+ await page.screenshot({ path: screenshot, fullPage: false });
+ await testInfo.attach("real-inbox", { path: screenshot, contentType: "image/png" });
+ expect(pageErrors).toEqual([]);
+ const authConsoleErrors = consoleErrors.filter((message) => message.includes("401 (Unauthorized)"));
+ expect(authConsoleErrors.length).toBeLessThanOrEqual(expectedAuthRejections);
+ expect(consoleErrors.filter((message) => !message.includes("401 (Unauthorized)"))).toEqual([]);
+ await testInfo.attach("browser-console", { body: JSON.stringify({ consoleErrors, pageErrors, expectedAuthRejections }), contentType: "application/json" });
+ const key = await createKey(browserApi, ["mail:read"]);
+ expect(key.secret).toBeTruthy();
+ const keyClient = await playwright.request.newContext({ extraHTTPHeaders: { authorization: `Bearer ${key.secret}` } });
+ try {
+ const list = await keyClient.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`);
+ expect(list.status()).toBe(200);
+ expect((await list.json()).messages[0].id).toBe(received.message.id);
+ expect((await browserApi.delete(`${apiUrl}/api/api-keys/${key.id}`)).status()).toBe(200);
+ expect((await keyClient.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`)).status()).toBe(401);
+ } finally { await keyClient.dispose(); await browserApi.dispose(); }
+});
+
+test("rejects unauthenticated test drivers and invalid invites", async ({ request }) => {
+ const driverResponse = await request.post(`${apiUrl}/__test/inbound`, { data: { to: "nobody@example.com", raw: "" } });
+ expect(driverResponse.status()).toBe(403);
+ const registration = await request.post(`${apiUrl}/api/auth/register`, { data: { email: "invalid-invite@example.com", password, name: "Invalid invite", inviteCode: "INVALID-BUSINESS-INVITE" } });
+ expect(registration.status()).toBe(403);
+ expect(registration.headers()["set-cookie"]).toBeUndefined();
+});
+
+test("enforces ownership, API scopes and per-user call quotas in D1", async ({ request, playwright }) => {
+ const email = await register(request, 2);
+ const mailbox = await createMailbox(request, "Permission inbox");
+ const inbound = await receive(request, mailbox, "permissions");
+ const other = await playwright.request.newContext();
+ const scoped = await playwright.request.newContext();
+ try {
+ await register(other, 3);
+ expect((await other.get(`${apiUrl}/api/mail/messages/${inbound.message.id}`)).status()).toBe(404);
+ const key = await createKey(request, ["mail:read"]);
+ const headers = { authorization: `Bearer ${key.secret}` };
+ expect((await scoped.post(`${apiUrl}/api/mail/send`, { headers, data: { mailboxId: mailbox.id, toAddress: "recipient@example.net", subject: "Forbidden", bodyText: "No send scope" } })).status()).toBe(403);
+ expect((await scoped.get(`${apiUrl}/api/notification/deliveries`, { headers })).status()).toBe(403);
+ await driver(request, "quota", { email, apiDailyLimit: 1 });
+ expect((await scoped.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`, { headers })).status()).toBe(200);
+ expect((await scoped.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`, { headers })).status()).toBe(429);
+ } finally { await other.dispose(); await scoped.dispose(); }
+});
+
+test("deduplicates Message-ID, preserves distinct mail and stores HTML attachments in R2", async ({ request }) => {
+ await register(request, 4);
+ const mailbox = await createMailbox(request, "MIME inbox");
+ const first = await receive(request, mailbox, "duplicate");
+ const duplicate = await receive(request, mailbox, "duplicate");
+ expect(duplicate.message.id).toBe(first.message.id);
+ await receive(request, mailbox, "distinct");
+ const mime = [
+ "From: Security ", `To: ${mailbox.address}`, "Message-ID: ", "Subject: HTML attachment",
+ 'MIME-Version: 1.0', 'Content-Type: multipart/mixed; boundary="business-boundary"', "", "--business-boundary",
+ 'Content-Type: text/html; charset="utf-8"', "", 'Your verification code is 482914.
', "--business-boundary",
+ 'Content-Type: text/plain; name="note.txt"', 'Content-Disposition: attachment; filename="note.txt"', 'Content-Transfer-Encoding: base64', "", "YnVzaW5lc3MtYXR0YWNobWVudA==", "--business-boundary--"
+ ].join("\r\n");
+ const { message } = await driver(request, "inbound", { to: mailbox.address, raw: mime });
+ const detail = await (await request.get(`${apiUrl}/api/mail/messages/${message.id}`)).json();
+ expect(detail.message.bodyText).toContain("482914");
+ expect(detail.message.attachments).toHaveLength(1);
+ const attachment = await request.get(`${apiUrl}/api/mail/messages/${message.id}/attachments/${detail.message.attachments[0].id}`);
+ expect(attachment.status()).toBe(200);
+ expect(await attachment.text()).toBe("business-attachment");
+ const messages = await (await request.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`)).json();
+ expect(messages.total).toBe(3);
+});
+
+test("shows notification failures, replays from the UI and explains rule matches", async ({ request, page }) => {
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
+ await register(request, 5);
+ const mailbox = await createMailbox(request, "Notifications inbox");
+ const endpoint = await request.post(`${apiUrl}/api/webhook/endpoints`, { data: { name: "Business webhook", url: "https://hooks.business.test/inbound", events: ["message.extracted"], enabled: true } });
+ expect(endpoint.status()).toBe(201);
+ await driver(request, "providers", { webhookFailures: 1 });
+ await receive(request, mailbox, "webhook-recovery");
+ const deliveries = await (await request.get(`${apiUrl}/api/webhook/deliveries?status=failed`)).json();
+ expect(deliveries.deliveries).toHaveLength(1);
+ expect(deliveries.deliveries[0].payload.data.extraction.value).toBe("654321");
+ const tasks = await (await request.get(`${apiUrl}/api/notification/deliveries`)).json();
+ expect(tasks.summary.counts.retrying).toBe(1);
+ const rule = await request.post(`${apiUrl}/api/notification/rules`, { data: { name: "Verification only", target: "webhook", eventTypes: ["message.extracted"], keyword: "verification", quietHoursTimezone: "Asia/Shanghai" } });
+ expect(rule.status()).toBe(201);
+ await page.goto("/login?next=/webhook");
+ await page.getByRole("textbox", { name: "邮箱", exact: true }).fill("business-member-5@example.com");
+ await page.locator("#login-password").fill(password);
+ await page.getByRole("button", { name: "立即登录", exact: true }).click();
+ await expect(page).toHaveURL(/\/webhook$/);
+ const panel = page.getByRole("region", { name: "通知状态", exact: true });
+ await expect(panel.getByText("HTTP 503: Controlled webhook outage", { exact: true })).toBeVisible();
+ await panel.getByRole("button", { name: `重试通知 ${tasks.deliveries[0].id}` }).click();
+ await expect(panel.getByText("投递成功 · 本轮已尝试 1 次", { exact: true })).toBeVisible();
+ const tester = page.getByRole("region", { name: "通知规则试运行" });
+ await tester.getByRole("textbox", { name: "测试邮件内容" }).fill("Other mail");
+ await tester.getByRole("button", { name: "测试通知规则" }).click();
+ await expect(tester.getByText("此事件未通过通知规则", { exact: true })).toBeVisible();
+ await expect(tester.getByText(/事件未匹配、关键词未匹配/)).toBeVisible();
+ await panel.screenshot({ path: "/tmp/wemail-notification-status-desktop.png" });
+ await page.setViewportSize({ width: 375, height: 812 });
+ await expect(panel.getByText("投递成功 · 本轮已尝试 1 次", { exact: true })).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+ await panel.screenshot({ path: "/tmp/wemail-notification-status-mobile.png" });
+ expect(pageErrors).toEqual([]);
+ expect(consoleErrors).toEqual([]);
+ const provider = await request.get(`${apiUrl}/__test/providers`, { headers: testHeaders });
+ const attempts = (await provider.json()).requests;
+ expect(attempts).toHaveLength(2);
+ expect(JSON.parse(attempts[1].body).data.extraction.value).toBe("654321");
+});
+
+test("recovers a failed notification automatically when its scheduled retry becomes due", async ({ request }) => {
+ await register(request, 7);
+ const mailbox = await createMailbox(request, "Automatic recovery");
+ expect((await request.post(`${apiUrl}/api/webhook/endpoints`, { data: { name: "Recovery webhook", url: "https://hooks.business.test/recovery", events: ["message.extracted"], enabled: true } })).status()).toBe(201);
+ await driver(request, "providers", { webhookFailures: 1 });
+ await receive(request, mailbox, "automatic-recovery");
+ const before = await (await request.get(`${apiUrl}/api/notification/deliveries`)).json();
+ expect(before.deliveries[0].status).toBe("retrying");
+ await driver(request, "dispatch", { now: before.deliveries[0].nextAttemptAt });
+ const after = await (await request.get(`${apiUrl}/api/notification/deliveries`)).json();
+ expect(after.deliveries[0]).toMatchObject({ eventId: before.deliveries[0].eventId, status: "succeeded", attempts: 2 });
+});
+
+test("sends with the real outbound route and enforces D1 quota after provider retry", async ({ request }) => {
+ const email = await register(request, 6);
+ const mailbox = await createMailbox(request, "Outbound inbox");
+ await driver(request, "providers", { resendFailures: 1 });
+ const payload = { mailboxId: mailbox.id, toAddress: "recipient@example.net", subject: "Business outbound", bodyText: "A controlled outbound test" };
+ const response = await request.post(`${apiUrl}/api/mail/send`, { data: payload });
+ expect(response.status(), await response.text()).toBe(200);
+ const outbound = await (await request.get(`${apiUrl}/api/mail/outbound?accountId=${mailbox.id}`)).json();
+ expect(outbound.messages[0].subject).toBe(payload.subject);
+ expect(outbound.messages[0].status).toBe("sent");
+ await driver(request, "quota", { email, dailyLimit: 0 });
+ expect((await request.post(`${apiUrl}/api/mail/send`, { data: payload })).status()).toBe(403);
+});
diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md
index 0b38dc7..c333bcf 100644
--- a/apps/web/e2e/README.md
+++ b/apps/web/e2e/README.md
@@ -1,6 +1,6 @@
# 🧪 e2e
-前端端到端测试目录。
+前端浏览器 smoke 与设计系统结构测试目录。
## ✅ 放什么
- Playwright 场景测试
@@ -12,17 +12,24 @@
## 📍 当前状态
-当前目录先提供骨架,后续可逐步补充:
-- 登录 / 注册流程
-- mailbox 创建
-- inbox 读取
-- admin 基本操作
-- `/design-system` 结构与视觉回归脚手架
+`smoke.spec.ts` 已覆盖首页、登录跳转与返回目标、成员工作台、邮件/账号设置、旧路由跳转、管理员用户与 dashboard 页面、公告查看与发布入口。`design-system.spec.ts` 覆盖公开 `/design-system` 的分组组件结构。
+
+当前配置只启动 Vite preview,业务 API 使用 `page.route()` 模拟。连接真实本地 Worker/D1/R2 的业务 E2E 位于 `../e2e-business`,使用独立 runner 与数据准备:`pnpm test:e2e:business`;外部供应商为受控适配器,真实 staging 外部链路的验证范围见[测试策略](../../../docs/testing-strategy.md)与[验收清单](../../../docs/staging-validation.md)。
+
+在仓库根目录运行:
+
+```bash
+pnpm test:e2e:install
+pnpm --dir apps/web exec vite build
+pnpm test:e2e
+```
+
+Playwright 配置启动 `http://127.0.0.1:4173` 的 preview,复用已有服务器;运行前确认该端口是当前构建产物。CI 会先构建 Web,再执行测试并上传报告与 raw test results。
## 📏 约定
- 端到端测试优先验证“用户路径”,不是内部实现细节
-- 当前阶段允许通过 `page.route()` 做 API mock,先保证前端壳层和交互路径稳定
-- 更接近真实后端联调的 e2e 可在后续独立 staging 环境中补充
+- 模拟 API 的页面回归和真实业务验证分别声明边界,不把 mock 响应当作后端或外部服务验收证据
+- 新增真实业务 E2E 后同步配置、运行命令、数据隔离与清理说明
- `design-system.spec.ts` 默认只跑结构断言;截图基线需在预览页示例稳定后,通过 `PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1` 显式开启
- `/design-system` 现在是公开页面;相关 e2e 不要求先登录或挂后台壳层
diff --git a/apps/web/playwright.business.config.ts b/apps/web/playwright.business.config.ts
new file mode 100644
index 0000000..5d5459e
--- /dev/null
+++ b/apps/web/playwright.business.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e-business",
+ workers: 1,
+ retries: 0,
+ timeout: 30_000,
+ outputDir: "test-results/business",
+ reporter: [["list"], ["html", { outputFolder: "playwright-report/business", open: "never" }]],
+ use: {
+ baseURL: process.env.BUSINESS_WEB_URL ?? "http://127.0.0.1:4187",
+ headless: true,
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure"
+ }
+});
diff --git a/apps/web/src/features/landing/HeroShowcase.tsx b/apps/web/src/features/landing/HeroShowcase.tsx
new file mode 100644
index 0000000..91a57be
--- /dev/null
+++ b/apps/web/src/features/landing/HeroShowcase.tsx
@@ -0,0 +1,179 @@
+import { useEffect, useMemo, useState } from "react";
+import { Check, Copy, Inbox, KeyRound, Link2, Timer } from "lucide-react";
+
+import { buildMessageExtraction, type ExtractionResult } from "@wemail/shared";
+
+// ---------------------------------------------------------------------------
+// Hero showcase: the landing page's job is to demonstrate the product, not
+// describe it. The demo card auto-plays the core loop (mail arrives → code
+// extracted → copied); the playground lets visitors paste any email text and
+// watch the real extraction engine (the same pure function the Worker runs)
+// work client-side.
+// ---------------------------------------------------------------------------
+
+const DEMO_STEP_DURATIONS = [700, 1000, 1200, 1500, 1700];
+const DEMO_FINAL_STEP = DEMO_STEP_DURATIONS.length - 1;
+
+function useDemoStep(reducedMotion: boolean) {
+ const [step, setStep] = useState(0);
+
+ useEffect(() => {
+ if (reducedMotion) {
+ setStep(DEMO_FINAL_STEP);
+ return;
+ }
+
+ const advance = (current: number) => {
+ const next = current >= DEMO_FINAL_STEP ? 0 : current + 1;
+ setStep(next);
+ window.setTimeout(() => advance(next), DEMO_STEP_DURATIONS[next]);
+ };
+
+ const timer = window.setTimeout(() => advance(0), DEMO_STEP_DURATIONS[0]);
+ return () => window.clearTimeout(timer);
+ }, [reducedMotion]);
+
+ return reducedMotion ? DEMO_FINAL_STEP : step;
+}
+
+export function HeroDemoCard({ reducedMotion }: { reducedMotion: boolean }) {
+ const step = useDemoStep(reducedMotion);
+
+ return (
+
+
+
+ ops@wemail.dev
+
+
+
+ 收件箱
+
+
+
+
+ {step === 0 ? (
+
等待新邮件…
+ ) : (
+
+
+ GitHub
+ 刚刚
+
+ [WeMail] Please verify your device
+ Your verification code is 482914. It expires in 10 minutes.
+
+ {step >= 2 ? (
+
+
+
+ 482914
+
+
+
+ 10 分钟内有效
+
+
+ ) : null}
+
+ )}
+
+
+
+ = 3 ? "landing-demo-copy is-copied" : "landing-demo-copy"}>
+ {step >= 3 ? : }
+ {step >= 3 ? "已复制 482914" : "复制验证码"}
+
+
+
+
+
+
+
+
+ );
+}
+
+const PLAYGROUND_SAMPLE = [
+ "您的验证码为 836-592,10 分钟内有效。",
+ "点击确认注册:https://app.example.com/verify?token=abc123",
+ "不希望再收到?退订:https://example.com/unsubscribe"
+].join("\n");
+
+const chipKindByType: Record = {
+ auth_code: "code",
+ auth_link: "link",
+ service_link: "link",
+ subscription_link: "link",
+ other_link: "link"
+};
+
+// The shared engine labels are English ("Verification code"); the landing is
+// Chinese-first, so chips display a localized label.
+const chipLabelByType: Record = {
+ auth_code: "验证码",
+ auth_link: "验证链接",
+ service_link: "服务链接",
+ subscription_link: "退订链接",
+ other_link: "有用链接"
+};
+
+function PlaygroundChip({ item }: { item: ExtractionResult }) {
+ const kind = chipKindByType[item.type] ?? "link";
+ const isCode = kind === "code";
+ const displayValue = isCode ? item.value : item.value.replace(/^https?:\/\//, "");
+
+ return (
+
+ {isCode ? (
+
+ ) : (
+
+ )}
+ {chipLabelByType[item.type] ?? item.label}
+ {displayValue}
+
+ );
+}
+
+export function HeroPlayground() {
+ const [text, setText] = useState(PLAYGROUND_SAMPLE);
+ const envelope = useMemo(() => buildMessageExtraction({ subject: "示例邮件", text }), [text]);
+
+ return (
+
+
+
试一试
+
粘贴任意邮件文本,看提取引擎工作
+
+ 这就是 Worker 收信时运行的同一个提取函数——验证码、链接分类、有效期,全部在你的浏览器里实时计算。
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/landing/WemailLandingPage.tsx b/apps/web/src/features/landing/WemailLandingPage.tsx
index 56ea62c..a14b505 100644
--- a/apps/web/src/features/landing/WemailLandingPage.tsx
+++ b/apps/web/src/features/landing/WemailLandingPage.tsx
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import {
Activity,
ArrowRight,
+ ArrowUpRight,
BookOpen,
Check,
Copy,
@@ -13,13 +14,13 @@ import {
Shield
} from "lucide-react";
-import { Button, ButtonLink } from "../../shared/button";
+import { Button, ButtonAnchor, ButtonLink } from "../../shared/button";
import { FloatingBackToTopButton } from "../../shared/FloatingBackToTopButton";
import { Switch } from "../../shared/switch";
import { Tabs, TabsList, TabsPanel, TabsTrigger } from "../../shared/tabs";
import { WemailBrandLockup } from "../../shared/WemailBrandLockup";
import { fetchPublicSystemHealth } from "./api";
-import { AnimatedSphere } from "./AnimatedSphere";
+import { HeroDemoCard, HeroPlayground } from "./HeroShowcase";
import { AnimatedTetrahedron } from "./AnimatedTetrahedron";
import { AnimatedWave } from "./AnimatedWave";
import { PublicSiteNavigation } from "./PublicSiteNavigation";
@@ -29,7 +30,6 @@ import {
developerTabs,
featureCards,
footerQuickLinks,
- heroStats,
heroWords,
infrastructureLocations,
integrations,
@@ -400,9 +400,6 @@ function HeroSection({ consoleHref, isAuthenticated }: LandingAuthCtaProps) {
return (
-
{Array.from({ length: 8 }).map((_, index) => (
@@ -412,30 +409,39 @@ function HeroSection({ consoleHref, isAuthenticated }: LandingAuthCtaProps) {
))}
-
-
- 给现代团队的临时邮箱工作台
-
-
- 把临时邮箱
-
- 做成能
-
-
- {currentWord.split("").map((character, index) => (
-
- {character}
-
- ))}
+
+
+
+ 开源
+ 自托管
+ Cloudflare 边缘
+
+
+ 给现代团队的临时邮箱工作台
+
+
+ 把临时邮箱
+
+ 做成能
+
+
+ {currentWord.split("").map((character, index) => (
+
+ {character}
+
+ ))}
+
+
-
+ 的系统
- 的系统
-
-
+
+
+
+ 为 QA、运营、客服和内部自动化提供统一工作台:收件、提取、外发与权限治理,同一界面完成。
+ 开源可自托管,部署在 Cloudflare 边缘,数据在你自己的账号里。
+
-
-
为 QA、运营、客服和内部自动化提供统一工作台,在同一界面完成收件、解析、外发与权限治理。
{isAuthenticated ? (
} variant="primary">
@@ -444,33 +450,28 @@ function HeroSection({ consoleHref, isAuthenticated }: LandingAuthCtaProps) {
) : (
<>
} variant="primary">
- 立即开始
-
-
- 进入登录
+ 申请邀请
+ }
+ variant="secondary"
+ >
+ 自托管部署
+
>
)}
-
-
-
- {Array.from({ length: 2 }).flatMap((_, loopIndex) =>
- heroStats.map((stat) => (
-
- {stat.value}
-
- {stat.label}
- {stat.company}
-
-
- ))
- )}
-
+
+
+
SCROLL
@@ -794,7 +795,7 @@ function TestimonialsSection() {
return (
-
团队反馈
+
场景示例
{String(activeIndex + 1).padStart(2, "0")} / {String(testimonials.length).padStart(2, "0")}
@@ -803,12 +804,9 @@ function TestimonialsSection() {
“{activeTestimonial.quote}”
-
{activeTestimonial.author.charAt(0)}
- {activeTestimonial.author}
-
- {activeTestimonial.role},{activeTestimonial.company}
-
+ {activeTestimonial.scenario}
+ 典型工作流场景
@@ -820,7 +818,7 @@ function TestimonialsSection() {
{testimonials.map((testimonial, index) => (
+
);
}
diff --git a/apps/web/src/features/settings/WebhookPage.tsx b/apps/web/src/features/settings/WebhookPage.tsx
index add223d..ada0764 100644
--- a/apps/web/src/features/settings/WebhookPage.tsx
+++ b/apps/web/src/features/settings/WebhookPage.tsx
@@ -61,6 +61,8 @@ import {
import { WebhookCodeBlock } from "./WebhookCodeBlock";
import { WebhookDeliveryDialog } from "./WebhookDeliveryDialog";
import { WebhookEndpointDialog } from "./WebhookEndpointDialog";
+import { NotificationDeliveryPanel } from "./NotificationDeliveryPanel";
+import { NotificationRuleTester } from "./NotificationRuleTester";
export function WebhookPage() {
const pushToast = useAppStore((state) => state.pushToast);
@@ -331,6 +333,7 @@ export function WebhookPage() {
name: notificationRuleDraft.name.trim(),
quietHoursEnd: notificationRuleDraft.quietHoursEnd,
quietHoursStart: notificationRuleDraft.quietHoursStart,
+ quietHoursTimezone: notificationRuleDraft.quietHoursTimezone,
target: notificationRuleDraft.target,
targetId: notificationRuleDraft.targetId.trim() || null
})
@@ -858,6 +861,8 @@ export function WebhookPage() {
/>
+ setNotificationRuleDraft((current) => ({ ...current, quietHoursTimezone: event.target.value }))} placeholder="例如:Asia/Shanghai" />
+ 免打扰按所选时区判断,可跨午夜;相同的起止时间表示关闭静默。
@@ -885,7 +890,7 @@ export function WebhookPage() {
{rule.mailboxIds.length > 0 ? `邮箱 ${rule.mailboxIds.length} 个` : "全部邮箱"}
{rule.keyword ? ` · 关键词 ${rule.keyword}` : ""}
- {rule.quietHoursStart && rule.quietHoursEnd ? ` · 静默 ${rule.quietHoursStart}-${rule.quietHoursEnd}` : ""}
+ {rule.quietHoursStart && rule.quietHoursEnd ? ` · 静默 ${rule.quietHoursStart}-${rule.quietHoursEnd} (${rule.quietHoursTimezone ?? "UTC"})` : ""}
+
+
diff --git a/apps/web/src/features/settings/api-interface-catalog.generated.ts b/apps/web/src/features/settings/api-interface-catalog.generated.ts
index 654e052..9a3363d 100644
--- a/apps/web/src/features/settings/api-interface-catalog.generated.ts
+++ b/apps/web/src/features/settings/api-interface-catalog.generated.ts
@@ -709,6 +709,39 @@ export const apiInterfaceGroups: ApiInterfaceGroup[] = [
"description": "读取当前用户的 Webhook/Telegram/外部目标通知规则。",
"access": "登录用户"
},
+ {
+ "method": "GET",
+ "path": "/api/notification/deliveries",
+ "title": "通知状态",
+ "description": "读取本人通知任务、精确状态计数和最近成功,可按 target 筛选。",
+ "access": "登录用户"
+ },
+ {
+ "method": "POST",
+ "path": "/api/notification/deliveries/:id/retry",
+ "title": "重放通知任务",
+ "description": "重试本人未过期、尚未在投递的失败或暂停任务。",
+ "access": "登录用户",
+ "example": {
+ "requestBody": null
+ }
+ },
+ {
+ "method": "POST",
+ "path": "/api/notification/rules/test",
+ "title": "试运行通知规则",
+ "description": "对样本事件返回匹配与抑制原因,不发送外部通知。",
+ "access": "登录用户",
+ "example": {
+ "requestBody": {
+ "target": "webhook",
+ "eventType": "message.received",
+ "data": {
+ "subject": "Verification code"
+ }
+ }
+ }
+ },
{
"method": "POST",
"path": "/api/notification/rules",
@@ -740,7 +773,8 @@ export const apiInterfaceGroups: ApiInterfaceGroup[] = [
"message.extraction.detected"
],
"quietHoursStart": "23:00",
- "quietHoursEnd": "08:00"
+ "quietHoursEnd": "08:00",
+ "quietHoursTimezone": "Asia/Shanghai"
}
}
},
@@ -900,6 +934,13 @@ export const apiInterfaceGroups: ApiInterfaceGroup[] = [
"description": "读取 D1/R2、migration、清理运行记录、幂等策略和备份恢复 runbook。",
"access": "管理员"
},
+ {
+ "method": "GET",
+ "path": "/api/system/notification-status",
+ "title": "全站通知状态",
+ "description": "读取管理员可见的全站通知状态计数与最近任务元数据。",
+ "access": "管理员"
+ },
{
"method": "GET",
"path": "/api/system/features",
diff --git a/apps/web/src/features/settings/notification-api.ts b/apps/web/src/features/settings/notification-api.ts
new file mode 100644
index 0000000..a8fcfe7
--- /dev/null
+++ b/apps/web/src/features/settings/notification-api.ts
@@ -0,0 +1,18 @@
+import type { NotificationDeliverySummary, NotificationRuleEvaluation, NotificationRuleTarget, NotificationStatusSummary } from "@wemail/shared";
+
+import { apiFetch } from "../../shared/api/client";
+
+export type NotificationStatusPayload = { summary: NotificationStatusSummary; deliveries: NotificationDeliverySummary[] };
+
+export function fetchNotificationStatus(options: { admin?: boolean; target?: NotificationRuleTarget; signal?: AbortSignal }) {
+ const path = options.admin ? "/api/system/notification-status" : `/api/notification/deliveries${options.target ? `?target=${options.target}` : ""}`;
+ return apiFetch(path, { signal: options.signal, dedupe: false });
+}
+
+export function retryNotificationDelivery(id: string) {
+ return apiFetch(`/api/notification/deliveries/${encodeURIComponent(id)}/retry`, { method: "POST" });
+}
+
+export function evaluateNotificationSample(input: { target: NotificationRuleTarget; eventType: string; targetId?: string; data: Record }) {
+ return apiFetch("/api/notification/rules/test", { method: "POST", body: JSON.stringify(input) });
+}
diff --git a/apps/web/src/features/settings/notification-status.css b/apps/web/src/features/settings/notification-status.css
new file mode 100644
index 0000000..221aab6
--- /dev/null
+++ b/apps/web/src/features/settings/notification-status.css
@@ -0,0 +1,15 @@
+.notification-status-panel, .notification-rule-tester { min-width: 0; }
+.notification-status-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
+.notification-status-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
+.notification-status-list { margin-top: 16px; }
+.notification-status-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border); }
+.notification-status-row > div { min-width: 0; }
+.notification-status-row p, .notification-status-row small { overflow-wrap: anywhere; }
+.notification-status-error { color: var(--text-muted); }
+.notification-rule-tester { margin-top: 20px; }
+.notification-rule-test-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 12px 0; }
+@media (max-width: 700px) {
+ .notification-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .notification-status-header, .notification-status-row { flex-wrap: wrap; }
+ .notification-rule-test-fields { grid-template-columns: minmax(0, 1fr); }
+}
diff --git a/apps/web/src/features/settings/webhook-content.ts b/apps/web/src/features/settings/webhook-content.ts
index 1b07725..978180f 100644
--- a/apps/web/src/features/settings/webhook-content.ts
+++ b/apps/web/src/features/settings/webhook-content.ts
@@ -127,6 +127,7 @@ export type WebhookDelivery = {
errorText: string | null;
payload?: unknown;
responseText?: string | null;
+ requestBodyText?: string | null;
createdAt: string;
};
@@ -138,6 +139,7 @@ export type NotificationRuleDraft = {
name: string;
quietHoursEnd: string;
quietHoursStart: string;
+ quietHoursTimezone: string;
target: NotificationRuleTarget;
targetId: string;
};
@@ -196,6 +198,7 @@ export const emptyNotificationRuleDraft: NotificationRuleDraft = {
name: "",
quietHoursEnd: "",
quietHoursStart: "",
+ quietHoursTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
target: "webhook",
targetId: ""
};
diff --git a/apps/web/src/pages/SystemSettingsPage.tsx b/apps/web/src/pages/SystemSettingsPage.tsx
index 1296adc..d1e1627 100644
--- a/apps/web/src/pages/SystemSettingsPage.tsx
+++ b/apps/web/src/pages/SystemSettingsPage.tsx
@@ -9,6 +9,7 @@ import type { WorkspaceTheme, WorkspaceThemePreference } from "../app/useWorkspa
import { FeatureTogglesPanel } from "../features/admin/FeatureTogglesPanel";
import { SystemDomainSettingsPanel } from "../features/settings/SystemDomainSettingsPanel";
import { SystemRuntimeSettingsPanel } from "../features/settings/SystemRuntimeSettingsPanel";
+import { NotificationDeliveryPanel } from "../features/settings/NotificationDeliveryPanel";
import { Badge } from "../shared/badge";
import { Button } from "../shared/button";
import { Page } from "../shared/page-layout";
@@ -177,6 +178,7 @@ export function SystemSettingsPage({
{canManageDomains ? : null}
+ {canManageRuntimeSettings ? : null}
);
}
diff --git a/apps/web/src/shared/overlay/README.md b/apps/web/src/shared/overlay/README.md
index e27b00e..d0ac8f0 100644
--- a/apps/web/src/shared/overlay/README.md
+++ b/apps/web/src/shared/overlay/README.md
@@ -21,3 +21,4 @@
- `description` 会自动接入 `aria-describedby`,用于补足弹层语义说明
- 默认通过 shared layer portal 渲染到 `document.body`
- 默认启用 focus trap、背景 inert、body scroll lock 和关闭后的焦点归还
+- 背景隔离与滚动锁在 DOM 提交阶段同步恢复;异步提交后关闭弹窗也不会留下已消失弹窗的不可交互背景
diff --git a/apps/web/src/shared/overlay/layer-utils.ts b/apps/web/src/shared/overlay/layer-utils.ts
index bcdbea0..d5dd612 100644
--- a/apps/web/src/shared/overlay/layer-utils.ts
+++ b/apps/web/src/shared/overlay/layer-utils.ts
@@ -85,7 +85,8 @@ export function useOverlayAccessibility({
panelRef: MutableRefObject;
portalRoot: HTMLElement | null;
}) {
- useEffect(() => {
+ // Restore background access in the same commit that removes the dialog, before the next paint.
+ useLayoutEffect(() => {
if (!active || !panelRef.current || !portalRoot) {
return undefined;
}
diff --git a/apps/web/src/test/api-keys-copy.test.tsx b/apps/web/src/test/api-keys-copy.test.tsx
new file mode 100644
index 0000000..f0cbfa6
--- /dev/null
+++ b/apps/web/src/test/api-keys-copy.test.tsx
@@ -0,0 +1,72 @@
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ApiKeysPage } from "../features/settings/ApiKeysPage";
+
+function renderApiKeys() {
+ const view = render(
+
+
+
+ );
+ fireEvent.click(screen.getByRole("button", { name: "展开调用示例" }));
+ return view;
+}
+
+describe("API key copy feedback lifecycle", () => {
+ let clipboardDescriptor: PropertyDescriptor | undefined;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ if (clipboardDescriptor) {
+ Object.defineProperty(navigator, "clipboard", clipboardDescriptor);
+ } else {
+ Reflect.deleteProperty(navigator, "clipboard");
+ }
+ });
+
+ it("releases the copy feedback timeout when leaving the API key page", async () => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText: vi.fn().mockResolvedValue(undefined) }
+ });
+ const view = renderApiKeys();
+ const copyButton = screen.getByRole("button", { name: "复制 Authorization Header" });
+
+ await act(async () => {
+ fireEvent.click(copyButton);
+ });
+ expect(copyButton).toHaveClass("is-copied");
+ expect(vi.getTimerCount()).toBe(1);
+
+ view.unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("does not start feedback after a pending clipboard operation completes on an unmounted page", async () => {
+ let completeCopy: () => void = () => undefined;
+ const clipboardResult = new Promise((resolve) => {
+ completeCopy = resolve;
+ });
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText: vi.fn().mockReturnValue(clipboardResult) }
+ });
+ const view = renderApiKeys();
+ fireEvent.click(screen.getByRole("button", { name: "复制 Authorization Header" }));
+ view.unmount();
+
+ await act(async () => {
+ completeCopy();
+ await clipboardResult;
+ });
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/apps/web/src/test/app.test.tsx b/apps/web/src/test/app.test.tsx
index 49911e0..2967a70 100644
--- a/apps/web/src/test/app.test.tsx
+++ b/apps/web/src/test/app.test.tsx
@@ -155,8 +155,11 @@ describe("App", () => {
"landing-nav-theme-toggle",
"landing-nav-edge-control"
);
- expect(screen.getAllByRole("link", { name: /立即开始/i }).length).toBeGreaterThan(0);
+ expect(screen.getAllByRole("link", { name: /申请邀请/i }).length).toBeGreaterThan(0);
expect(screen.getAllByRole("link", { name: /进入登录/i }).length).toBeGreaterThan(0);
+ expect(screen.getByRole("link", { name: /自托管部署/i })).toHaveAttribute("href", "https://doc.wemail.willxue.com");
+ expect(screen.getByRole("heading", { level: 2, name: /粘贴任意邮件文本/i })).toBeInTheDocument();
+ expect(screen.getByRole("region", { name: "产品演示:收信与验证码提取" })).toBeInTheDocument();
},
10000
);
@@ -214,7 +217,7 @@ describe("App", () => {
const cta = within(ctaRow as HTMLElement).getByRole("link", { name: /^进入控制台$/i });
expect(cta).toHaveClass("ui-button", "ui-button-primary");
expect(cta).toHaveAttribute("href", "/mail/list");
- expect(within(ctaRow as HTMLElement).queryByRole("link", { name: /立即开始|受邀注册|进入登录/i })).not.toBeInTheDocument();
+ expect(within(ctaRow as HTMLElement).queryByRole("link", { name: /立即开始|申请邀请|自托管部署|受邀注册|进入登录/i })).not.toBeInTheDocument();
}
expect(window.location.pathname).toBe("/");
},
diff --git a/apps/web/src/test/integration/README.md b/apps/web/src/test/integration/README.md
index 0594cba..243e54e 100644
--- a/apps/web/src/test/integration/README.md
+++ b/apps/web/src/test/integration/README.md
@@ -1,6 +1,6 @@
# 🔍 integration
-前端集成测试骨架目录。
+前端路由、页面与 feature 协同测试目录。
## ✅ 放什么
- 页面级关键交互测试
@@ -12,7 +12,6 @@
## 📍 当前状态
-当前目录先提供结构占位,后续建议补充:
-- inbox workflow integration
-- admin dashboard integration
-- settings / api key integration
+当前已有邮箱与收件箱流程、发件、管理员 dashboard、公告、账号/邮件/系统设置、个人资料与 API Key 等页面测试。通过 Testing Library 渲染生产组件,主要用模拟 `fetch` 响应驱动交互。
+
+这些测试验证前端在给定 API 结果下的行为,不启动真实 Worker/D1 或邮件供应商。在仓库根目录运行 `pnpm test:web:integration`;真实业务与 staging 外部链路验证的边界见[测试策略](../../../../../docs/testing-strategy.md)。
diff --git a/apps/web/src/test/integration/settings-page.test.tsx b/apps/web/src/test/integration/settings-page.test.tsx
index b7e290d..fd1c2fb 100644
--- a/apps/web/src/test/integration/settings-page.test.tsx
+++ b/apps/web/src/test/integration/settings-page.test.tsx
@@ -337,7 +337,8 @@ describe("settings pages", () => {
renderWithRouter();
expect(screen.queryByRole("heading", { name: /事件订阅/i })).not.toBeInTheDocument();
- expect(screen.getByRole("heading", { name: /通知规则/i })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: /^通知规则$/, level: 2 })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "通知规则试运行", level: 3 })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /开发者参考/i })).toBeInTheDocument();
expect(screen.getByText("Webhook", { selector: ".panel-kicker" })).toBeInTheDocument();
expect(screen.getByText("端点列表", { selector: ".panel-kicker" })).toBeInTheDocument();
diff --git a/apps/web/src/test/notification-status.test.tsx b/apps/web/src/test/notification-status.test.tsx
new file mode 100644
index 0000000..e4b9433
--- /dev/null
+++ b/apps/web/src/test/notification-status.test.tsx
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { NotificationDeliveryPanel } from "../features/settings/NotificationDeliveryPanel";
+import { NotificationRuleTester } from "../features/settings/NotificationRuleTester";
+
+const summary = { counts: { pending: 0, processing: 0, retrying: 0, succeeded: 0, failed: 1, suppressed: 0 }, backlogCount: 0, lastSucceededAt: null };
+const delivery = { id: "task-1", eventId: "event-1", eventType: "message.received", target: "webhook", targetId: "endpoint-1", targetName: "Codes webhook", status: "failed", attempts: 1, nextAttemptAt: "2026-10-06T00:00:00Z", errorText: "HTTP 503", createdAt: "2026-10-06T00:00:00Z", updatedAt: "2026-10-06T00:00:00Z", expiresAt: "2099-01-01T00:00:00Z" };
+
+describe("notification status and rule testing", () => {
+ afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
+
+ it("shows a failed target, replays it and reloads its successful state", async () => {
+ const successful = { summary: { ...summary, counts: { ...summary.counts, failed: 0, succeeded: 1 } }, deliveries: [{ ...delivery, status: "succeeded", attempts: 2, errorText: null }] };
+ const fetchMock = vi.fn().mockResolvedValueOnce(Response.json({ summary, deliveries: [delivery] }))
+ .mockResolvedValueOnce(Response.json(successful)).mockResolvedValueOnce(Response.json(successful));
+ vi.stubGlobal("fetch", fetchMock);
+ render();
+ expect(await screen.findByText("HTTP 503")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "重试通知 task-1" }));
+ await waitFor(() => expect(screen.getByText("投递成功 · 本轮已尝试 2 次")).toBeInTheDocument());
+ expect(fetchMock.mock.calls[1][0]).toContain("/api/notification/deliveries/task-1/retry");
+ expect(fetchMock.mock.calls[1][1]?.method).toBe("POST");
+ });
+
+ it("keeps a retryable loading error local to the notification panel", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("network unavailable")).mockResolvedValueOnce(Response.json({ summary, deliveries: [] })));
+ render();
+ expect(await screen.findByRole("alert")).toHaveTextContent("network unavailable");
+ fireEvent.click(screen.getByRole("button", { name: "刷新通知状态" }));
+ expect(await screen.findByText(/暂无通知记录/)).toBeInTheDocument();
+ });
+
+ it("displays the conditions that suppressed a dry-run event", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(Response.json({
+ shouldSend: false, reason: "no_matching_rules", evaluatedAt: "2026-10-06T00:00:00Z",
+ rules: [{ id: "rule-1", name: "Night codes", matched: false, reasons: ["quiet_hours", "keyword_mismatch"], quietHoursTimezone: "Asia/Shanghai" }]
+ }));
+ vi.stubGlobal("fetch", fetchMock);
+ render();
+ fireEvent.change(screen.getByRole("textbox", { name: "测试邮件内容" }), { target: { value: "Verification sample" } });
+ fireEvent.click(screen.getByRole("button", { name: "测试通知规则" }));
+ expect(await screen.findByText("此事件未通过通知规则")).toBeInTheDocument();
+ expect(screen.getByText(/处于免打扰时间、关键词未匹配/)).toBeInTheDocument();
+ expect(screen.getByText(/时区 Asia\/Shanghai/)).toBeInTheDocument();
+ expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body)).data.subject).toBe("Verification sample");
+ });
+});
diff --git a/apps/web/src/test/shared-overlay.test.tsx b/apps/web/src/test/shared-overlay.test.tsx
index b5871b8..b91fffd 100644
--- a/apps/web/src/test/shared-overlay.test.tsx
+++ b/apps/web/src/test/shared-overlay.test.tsx
@@ -1,12 +1,40 @@
import { readFileSync } from "node:fs";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { useLayoutEffect, useRef, useState } from "react";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OverlayDialog, OverlayDrawer } from "../shared/overlay";
const sharedStyles = readFileSync("src/shared/styles/index.css", "utf8");
+function AsyncClosingDialog({ onClosed }: { onClosed: (isBackgroundHidden: boolean, overflow: string) => void }) {
+ const [isOpen, setIsOpen] = useState(true);
+ const shellRef = useRef(null);
+
+ useLayoutEffect(() => {
+ if (!isOpen) {
+ onClosed(shellRef.current?.parentElement?.getAttribute("aria-hidden") === "true", document.body.style.overflow);
+ }
+ }, [isOpen, onClosed]);
+
+ return (
+
+
恢复后的操作
+ {isOpen ? (
+
{
+ void Promise.resolve().then(() => setIsOpen(false));
+ }}
+ title="异步关闭"
+ >
+ 提交完成后关闭弹窗。
+
+ ) : null}
+
+ );
+}
+
describe("shared overlay primitives", () => {
afterEach(() => {
cleanup();
@@ -96,4 +124,27 @@ describe("shared overlay primitives", () => {
fireEvent.keyDown(dialog, { key: "Tab" });
expect(closeButton).toHaveFocus();
});
+
+ it("restores background accessibility and scrolling before painting an asynchronously closed dialog", async () => {
+ const onClosed = vi.fn();
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "auto";
+
+ try {
+ render();
+ expect(screen.queryByRole("button", { name: "恢复后的操作" })).not.toBeInTheDocument();
+ expect(document.body.style.overflow).toBe("hidden");
+
+ fireEvent.click(screen.getByRole("button", { name: "关闭弹层" }));
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog", { name: "异步关闭" })).not.toBeInTheDocument();
+ });
+
+ expect(onClosed).toHaveBeenCalledWith(false, "auto");
+ expect(screen.getByRole("button", { name: "恢复后的操作" })).toBeInTheDocument();
+ } finally {
+ cleanup();
+ document.body.style.overflow = previousOverflow;
+ }
+ });
});
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 04206a7..6f98afc 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -28,7 +28,7 @@ export default defineConfig({
}
},
test: {
- exclude: ["e2e/**", "node_modules/**", "dist/**"],
+ exclude: ["e2e/**", "e2e-business/**", "node_modules/**", "dist/**"],
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"]
}
diff --git a/apps/worker/src/app/runtime.ts b/apps/worker/src/app/runtime.ts
index 094a040..e1e7289 100644
--- a/apps/worker/src/app/runtime.ts
+++ b/apps/worker/src/app/runtime.ts
@@ -5,8 +5,9 @@ import { buildMessageExtraction, createPreview, maybeRunAiFallback, parseRawEmai
import { recordAudit } from "./services/audit-service";
import { defaultFeatureToggles } from "./services/config-service";
import { getRuntimeSettings } from "./services/runtime-settings-service";
-import { sendTelegramNotification } from "./services/telegram-service";
-import { sendWebhookEventToUser } from "./services/webhook-service";
+import { planNotificationEvent, processNotificationOutbox } from "./services/notification-outbox-service";
+
+type InboundProcessingOptions = { deferNotifications?: (work: Promise) => void };
function normalizeRecipientAddress(address: string) {
return address.trim().toLowerCase();
@@ -118,6 +119,7 @@ async function saveInboundMessage(
attachments: Array<{ filename: string; contentType: string; data: Uint8Array; size: number }>;
};
extraction: ReturnType;
+ notificationContext?: { userId: string; address: string; featureToggles: Awaited> };
}
) {
const duplicate = await findRecentDuplicateMessage(store, input);
@@ -126,6 +128,28 @@ async function saveInboundMessage(
const settings = await getRuntimeSettings(store, env);
const { acceptedAttachments, oversizeStatus } = collectAcceptedAttachments(settings, input.parsed.attachments);
const expiresAt = new Date(Date.now() + settings.message.retentionDays * 24 * 60 * 60 * 1000).toISOString();
+ const id = crypto.randomUUID();
+ const context = input.notificationContext;
+ const data = {
+ mailboxId: input.mailboxId, mailboxAddress: context?.address ?? input.toAddress,
+ messageId: id, fromAddress: input.parsed.fromAddress, subject: input.parsed.subject
+ };
+ const common = context ? { store, featureToggles: context.featureToggles, userId: context.userId, expiresAt } : null;
+ const tasks = common ? await planNotificationEvent({
+ ...common, eventId: `message:${id}:received`, eventType: "message.received", data,
+ telegramText: `New mail for ${data.mailboxAddress}\nFrom: ${data.fromAddress}\nSubject: ${data.subject}`
+ }) : [];
+ if (common && input.extraction.primary.type !== "none") {
+ tasks.push(...await planNotificationEvent({
+ ...common, eventId: `message:${id}:extracted`, eventType: "message.extracted",
+ data: { ...data, extraction: input.extraction.primary, extractions: input.extraction.items, expiresHint: input.extraction.expiresHint, authSummary: input.extraction.authSummary },
+ telegramText: [
+ `Extracted results for ${data.mailboxAddress}`,
+ ...input.extraction.items.map((item) => `${item.label}: ${item.value}`),
+ input.extraction.expiresHint, `Subject: ${data.subject}`
+ ].filter(Boolean).join("\n")
+ }));
+ }
const message = await store.messages.create({
mailboxId: input.mailboxId,
toAddress: input.toAddress,
@@ -139,7 +163,8 @@ async function saveInboundMessage(
attachmentCount: acceptedAttachments.length,
receivedAt: new Date().toISOString(),
expiresAt
- });
+ }, { id, notificationTasks: tasks });
+ if (message.id !== id) return { message, oversizeStatus: message.oversizeStatus, duplicateSuppressed: true };
const attachmentRecords = acceptedAttachments.map((attachment) => ({
id: crypto.randomUUID(),
@@ -175,7 +200,8 @@ async function processInboundForMailbox(
subject: string;
text: string;
attachments: Array<{ filename: string; contentType: string; data: Uint8Array; size: number }>;
- }
+ },
+ options: InboundProcessingOptions
) {
let extraction = buildMessageExtraction({
subject: parsed.subject,
@@ -202,7 +228,8 @@ async function processInboundForMailbox(
mailboxId: mailbox.id,
toAddress,
parsed,
- extraction
+ extraction,
+ notificationContext: { userId: mailbox.userId, address: mailbox.address, featureToggles }
});
if (duplicateSuppressed) {
@@ -213,45 +240,13 @@ async function processInboundForMailbox(
return message;
}
- await sendTelegramNotification(
- { store, env, featureToggles },
- {
- userId: mailbox.userId,
- eventId: "message.received",
- text: `New mail for ${mailbox.address}\nFrom: ${parsed.fromAddress}\nSubject: ${parsed.subject}`,
- metadata: { mailboxId: mailbox.id, messageId: message.id }
- }
- );
- await sendWebhookEventToUser(store, mailbox.userId, "message.received", {
- mailboxAddress: mailbox.address,
- mailboxId: mailbox.id,
- messageId: message.id,
- fromAddress: parsed.fromAddress,
- subject: parsed.subject
+ // Metadata and notification intents committed together. Dispatch failures
+ // leave a recoverable task, while the Email handler acknowledges stored mail.
+ const dispatch = processNotificationOutbox(store, env, { limit: 4 }).catch((error) => {
+ globalThis.console.error(JSON.stringify({ event: "notification.dispatch.error", messageId: message.id, error: error instanceof Error ? error.message : "Dispatch failed" }));
});
-
- if (extraction.primary.type !== "none") {
- await sendTelegramNotification(
- { store, env, featureToggles },
- {
- userId: mailbox.userId,
- eventId: "message.extraction.detected",
- text: `Extracted result for ${mailbox.address}\n${extraction.primary.label}: ${extraction.primary.value}\nSubject: ${parsed.subject}`,
- metadata: { mailboxId: mailbox.id, messageId: message.id, extractionType: extraction.primary.type }
- }
- );
- // extraction (primary) keeps the legacy single-result contract; the
- // envelope fields carry every finding plus expiry and auth verdicts.
- await sendWebhookEventToUser(store, mailbox.userId, "message.extracted", {
- mailboxAddress: mailbox.address,
- mailboxId: mailbox.id,
- messageId: message.id,
- extraction: extraction.primary,
- extractions: extraction.items,
- expiresHint: extraction.expiresHint,
- authSummary: extraction.authSummary
- });
- }
+ if (options.deferNotifications) options.deferNotifications(dispatch);
+ else await dispatch;
await recordAudit(store, "user", mailbox.userId, "message-received", {
mailboxId: mailbox.id,
@@ -266,7 +261,8 @@ async function processInboundForMailbox(
export async function processInboundEmail(
env: AppBindings,
store: AppStore,
- message: { to: string; raw: ReadableStream }
+ message: { to: string; raw: ReadableStream },
+ options: InboundProcessingOptions = {}
) {
const toAddress = normalizeRecipientAddress(message.to);
const parsed = await parseRawEmail(message.raw);
@@ -285,7 +281,7 @@ export async function processInboundEmail(
});
return unmatchedMessage;
}
- return processInboundForMailbox(store, env, mailbox, toAddress, parsed);
+ return processInboundForMailbox(store, env, mailbox, toAddress, parsed, options);
}
// Cleanup must finish within one cron invocation; bounding each phase keeps the
diff --git a/apps/worker/src/app/services/README.md b/apps/worker/src/app/services/README.md
index 60c6cda..de220f5 100644
--- a/apps/worker/src/app/services/README.md
+++ b/apps/worker/src/app/services/README.md
@@ -11,6 +11,7 @@
- 一个文件只解决一个稳定问题域,例如 `audit-service.ts`、`session-service.ts`
- routes / use-cases 直接依赖具体 service,不再通过桶文件统一转发
- service 可以依赖 `core/` 契约与 `shared/` 纯工具,但不要反向依赖 routes
+- `notification-outbox-service.ts` 准备通知意图并调度持久化投递;提交一致性、租约和外部投递边界见 `docs/adr/0003-durable-notification-outbox.md`。
## 🚫 不放什么
- 路由注册
diff --git a/apps/worker/src/app/services/notification-outbox-service.ts b/apps/worker/src/app/services/notification-outbox-service.ts
new file mode 100644
index 0000000..f2da5de
--- /dev/null
+++ b/apps/worker/src/app/services/notification-outbox-service.ts
@@ -0,0 +1,163 @@
+import type { FeatureToggles, TelegramDeliveryEventId } from "@wemail/shared";
+
+import type { AppBindings, AppStore, NotificationOutboxInput, NotificationOutboxRecord } from "../../core/bindings";
+import { defaultFeatureToggles } from "./config-service";
+import { shouldSendNotificationToTarget } from "./notification-rule-service";
+import { sendTelegramNotification } from "./telegram-service";
+import { retryWebhookDelivery, sendWebhookEventToEndpoint } from "./webhook-service";
+import { getRuntimeSettings } from "./runtime-settings-service";
+
+type NotificationEventInput = {
+ store: AppStore;
+ featureToggles: Pick;
+ userId: string;
+ eventId: string;
+ eventType: string;
+ data: Record;
+ expiresAt?: string;
+ telegramText?: string;
+};
+
+function telegramEvent(eventType: string): TelegramDeliveryEventId | null {
+ if (eventType === "message.extracted") return "message.extraction.detected";
+ if (["message.received", "message.extraction.detected", "api_key.created", "api_key.revoked", "telegram.test"].includes(eventType)) return eventType as TelegramDeliveryEventId;
+ return null;
+}
+
+export async function planNotificationEvent(input: NotificationEventInput): Promise {
+ const records: NotificationOutboxInput[] = [];
+ const common = {
+ messageId: typeof input.data.messageId === "string" ? input.data.messageId : null,
+ eventId: input.eventId, userId: input.userId, eventType: input.eventType,
+ payloadJson: JSON.stringify({ data: input.data, telegramText: input.telegramText }),
+ expiresAt: input.expiresAt ?? new Date(Date.now() + 7 * 86_400_000).toISOString()
+ };
+ const endpoints = await input.store.webhookEndpoints.listByUser(input.userId);
+ for (const endpoint of endpoints) {
+ let events: unknown;
+ try { events = JSON.parse(endpoint.eventsJson); } catch { continue; }
+ if (!endpoint.enabled || !Array.isArray(events) || !events.includes(input.eventType)) continue;
+ const target = endpoint.channel ?? "webhook";
+ if (await shouldSendNotificationToTarget(input.store, input.userId, { data: input.data, eventType: input.eventType, target, targetId: endpoint.id })) {
+ records.push({ ...common, target, targetId: endpoint.id });
+ }
+ }
+ const event = telegramEvent(input.eventType);
+ if (input.featureToggles.telegramEnabled && event) {
+ const subscription = await input.store.telegram.findByUserId(input.userId);
+ if (subscription?.enabled && await shouldSendNotificationToTarget(input.store, input.userId, {
+ data: { ...input.data, text: input.telegramText ?? "" }, eventType: event, target: "telegram", targetId: subscription.chatId
+ })) records.push({ ...common, target: "telegram", targetId: subscription.chatId });
+ }
+ return records;
+}
+
+export async function enqueueNotificationEvent(input: NotificationEventInput) {
+ const planned = await planNotificationEvent(input);
+ return Promise.all(planned.map((record) => input.store.notificationOutbox.enqueue(record)));
+}
+
+function parsePayload(record: NotificationOutboxRecord) {
+ const payload = JSON.parse(record.payloadJson) as { data?: Record; telegramText?: string };
+ if (!payload.data || typeof payload.data !== "object" || Array.isArray(payload.data)) throw new Error("Original notification event is unavailable");
+ return payload;
+}
+
+function retryAt(attempts: number, now: Date, retryAfterMs = 0) {
+ if (attempts >= 5) return undefined;
+ const delay = Math.max(Math.min(30_000 * 2 ** (attempts - 1), 3_600_000), Math.min(retryAfterMs, 3_600_000));
+ return new Date(now.getTime() + delay).toISOString();
+}
+
+async function dispatchRecord(store: AppStore, env: AppBindings, record: NotificationOutboxRecord, now: Date) {
+ if (!record.leaseToken) throw new Error("A delivery lease is required");
+ const startedAt = Date.now();
+ const completedAt = () => new Date(now.getTime() + Math.max(0, Date.now() - startedAt));
+ try {
+ const payload = parsePayload(record);
+ if (record.target === "telegram") {
+ const eventId = telegramEvent(record.eventType);
+ const subscription = await store.telegram.findByUserId(record.userId);
+ const toggles = await store.settings.getFeatureToggles(defaultFeatureToggles(env));
+ if (!eventId || !subscription?.enabled || subscription.chatId !== record.targetId || !toggles.telegramEnabled) {
+ await store.notificationOutbox.markFailed(record.id, { leaseToken: record.leaseToken, error: "Telegram target is disabled or has changed", suppressed: true });
+ return {};
+ }
+ const result = await sendTelegramNotification({ store, env, featureToggles: toggles }, {
+ userId: record.userId, eventId, text: payload.telegramText ?? "",
+ // Delivery audit metadata must not duplicate retained verification codes.
+ metadata: { notificationTaskId: record.id, stableEventId: record.eventId, mailboxId: payload.data!.mailboxId, messageId: payload.data!.messageId }
+ }, { skipRuleCheck: true });
+ if (result.delivered) await store.notificationOutbox.markSucceeded(record.id, record.leaseToken);
+ else await store.notificationOutbox.markFailed(record.id, {
+ leaseToken: record.leaseToken, error: result.reason ?? "Telegram delivery failed",
+ suppressed: result.reason === "notification_rule_suppressed",
+ retryAt: result.reason === "telegram_request_failed" || (result.reason === "telegram_api_failed" && (result.statusCode == null || result.statusCode === 408 || result.statusCode === 429 || result.statusCode >= 500)) ? retryAt(record.attempts, completedAt(), result.retryAfterMs) : undefined
+ });
+ return {};
+ }
+ const endpoint = (await store.webhookEndpoints.listByUser(record.userId)).find((entry) => entry.id === record.targetId);
+ if (!endpoint?.enabled || (endpoint.channel ?? "webhook") !== record.target) {
+ await store.notificationOutbox.markFailed(record.id, { leaseToken: record.leaseToken, error: "Webhook target is disabled, removed or has changed", suppressed: true });
+ return {};
+ }
+ const delivery = await sendWebhookEventToEndpoint(store, endpoint, record.eventType, payload.data!, {
+ eventId: record.eventId, notificationTaskId: record.id
+ });
+ if (delivery.status === "success") await store.notificationOutbox.markSucceeded(record.id, record.leaseToken);
+ else {
+ const canRetry = delivery.statusCode === null || delivery.statusCode === 408 || delivery.statusCode === 429 || delivery.statusCode >= 500;
+ await store.notificationOutbox.markFailed(record.id, {
+ leaseToken: record.leaseToken, error: delivery.errorText ?? "Webhook delivery failed",
+ retryAt: canRetry ? retryAt(record.attempts, completedAt(), delivery.retryAfterMs) : undefined
+ });
+ }
+ return { delivery };
+ } catch (error) {
+ await store.notificationOutbox.markFailed(record.id, {
+ leaseToken: record.leaseToken, error: error instanceof Error ? error.message : "Notification delivery failed",
+ retryAt: error instanceof SyntaxError || (error instanceof Error && error.message === "Original notification event is unavailable") ? undefined : retryAt(record.attempts, completedAt())
+ });
+ return {};
+ }
+}
+
+export async function processNotificationOutbox(store: AppStore, env: AppBindings, options: { limit?: number; now?: Date } = {}) {
+ const now = options.now ?? new Date();
+ const settings = await getRuntimeSettings(store, env);
+ await store.notificationOutbox.deleteExpired(now.toISOString(), new Date(now.getTime() - settings.message.retentionDays * 86_400_000).toISOString());
+ const records = await store.notificationOutbox.claimDue({
+ nowIso: now.toISOString(), lockBeforeIso: new Date(now.getTime() - 120_000).toISOString(),
+ limit: Math.min(Math.max(options.limit ?? 20, 1), 20)
+ });
+ const startedAt = Date.now();
+ // Four requests at a time keep a bounded batch within the two-minute lease,
+ // while a failed target cannot prevent other targets from being attempted.
+ for (let offset = 0; offset < records.length; offset += 4) {
+ await Promise.all(records.slice(offset, offset + 4).map((record) => dispatchRecord(store, env, record, new Date(now.getTime() + Math.max(0, Date.now() - startedAt)))));
+ }
+ return { claimed: records.length };
+}
+
+export async function replayNotificationTask(store: AppStore, env: AppBindings, userId: string, taskId: string) {
+ const now = new Date();
+ const replayed = await store.notificationOutbox.replay(taskId, userId, now.toISOString());
+ if (!replayed) return null;
+ const [claimed] = await store.notificationOutbox.claimDue({
+ nowIso: now.toISOString(), lockBeforeIso: new Date(now.getTime() - 120_000).toISOString(),
+ limit: 1, taskId, userId
+ });
+ if (!claimed) throw new Error("Notification is already being delivered");
+ const result = await dispatchRecord(store, env, claimed, now);
+ return { ...result, task: (await store.notificationOutbox.listByUser(userId, 500)).find((entry) => entry.id === taskId) ?? replayed };
+}
+
+export async function retryWebhookDeliveryWithOutbox(store: AppStore, env: AppBindings, userId: string, deliveryId: string) {
+ const delivery = await store.webhookDeliveries.findByUser(deliveryId, userId);
+ if (!delivery) return null;
+ const payload = JSON.parse(delivery.payloadJson) as { notificationTaskId?: string };
+ if (!payload.notificationTaskId) return retryWebhookDelivery(store, userId, deliveryId);
+ const replayed = await replayNotificationTask(store, env, userId, payload.notificationTaskId);
+ if (!replayed?.delivery) throw new Error("Notification is expired, suppressed or already being delivered");
+ return replayed.delivery;
+}
diff --git a/apps/worker/src/app/services/notification-rule-service.ts b/apps/worker/src/app/services/notification-rule-service.ts
index 71ecc74..c01fb1d 100644
--- a/apps/worker/src/app/services/notification-rule-service.ts
+++ b/apps/worker/src/app/services/notification-rule-service.ts
@@ -1,4 +1,4 @@
-import type { NotificationRuleInput, NotificationRuleSummary, NotificationRuleTarget } from "@wemail/shared";
+import type { NotificationRuleEvaluation, NotificationRuleInput, NotificationRuleSummary, NotificationRuleTarget } from "@wemail/shared";
import type { AppStore, NotificationRuleRecord } from "../../core/bindings";
@@ -16,6 +16,10 @@ const notificationEvents = new Set([
"settings.updated"
]);
+export function isSupportedNotificationEvent(value: unknown): value is string {
+ return typeof value === "string" && notificationEvents.has(value);
+}
+
function parseJsonStringArray(value: string) {
try {
const parsed = JSON.parse(value);
@@ -47,13 +51,20 @@ function normalizeQuietHour(value: unknown) {
return trimmed;
}
-function isInQuietHours(start: string, end: string, now = new Date()) {
+function isInQuietHours(start: string, end: string, now = new Date(), timeZone = "UTC") {
if (!start || !end || start === end) return false;
- const current = `${String(now.getUTCHours()).padStart(2, "0")}:${String(now.getUTCMinutes()).padStart(2, "0")}`;
+ const current = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(now);
if (start < end) return current >= start && current < end;
return current >= start || current < end;
}
+function normalizeTimezone(value: unknown) {
+ if (value === undefined) return "UTC";
+ if (typeof value !== "string" || !value.trim() || value.length > 120) throw new Error("A valid IANA timezone is required");
+ try { return new Intl.DateTimeFormat("en-GB", { timeZone: value.trim() }).resolvedOptions().timeZone; }
+ catch { throw new Error("A valid IANA timezone is required"); }
+}
+
export function toNotificationRuleSummary(record: NotificationRuleRecord): NotificationRuleSummary {
return {
id: record.id,
@@ -66,6 +77,7 @@ export function toNotificationRuleSummary(record: NotificationRuleRecord): Notif
keyword: record.keyword,
quietHoursStart: record.quietHoursStart,
quietHoursEnd: record.quietHoursEnd,
+ quietHoursTimezone: record.quietHoursTimezone ?? "UTC",
createdAt: record.createdAt,
updatedAt: record.updatedAt
};
@@ -90,7 +102,8 @@ export function parseNotificationRulePayload(payload: unknown): NotificationRule
mailboxIds: normalizeStringList(input.mailboxIds, { maxItems: 100 }),
keyword: typeof input.keyword === "string" ? input.keyword.trim().slice(0, 120) : "",
quietHoursStart: normalizeQuietHour(input.quietHoursStart),
- quietHoursEnd: normalizeQuietHour(input.quietHoursEnd)
+ quietHoursEnd: normalizeQuietHour(input.quietHoursEnd),
+ quietHoursTimezone: normalizeTimezone(input.quietHoursTimezone)
};
}
@@ -105,7 +118,8 @@ export function toNotificationRuleRecordInput(userId: string, input: Notificatio
mailboxIdsJson: JSON.stringify(input.mailboxIds ?? []),
keyword: input.keyword ?? "",
quietHoursStart: input.quietHoursStart ?? "",
- quietHoursEnd: input.quietHoursEnd ?? ""
+ quietHoursEnd: input.quietHoursEnd ?? "",
+ quietHoursTimezone: input.quietHoursTimezone ?? "UTC"
};
}
@@ -136,7 +150,7 @@ function matchesRuleEvent(rule: NotificationRuleRecord, eventType: string) {
return parseJsonStringArray(rule.eventTypesJson).includes(eventType);
}
-export async function shouldSendNotificationToTarget(
+export async function evaluateNotificationRules(
store: AppStore,
userId: string,
input: {
@@ -144,17 +158,28 @@ export async function shouldSendNotificationToTarget(
eventType: string;
target: NotificationRuleTarget;
targetId?: string | null;
+ now?: Date;
}
-) {
- const rules = (await store.notificationRules.listByUser(userId)).filter((rule) => rule.enabled && rule.target === input.target);
- if (rules.length === 0) return true;
-
- return rules.some(
- (rule) =>
- matchesRuleTarget(rule, input) &&
- matchesRuleEvent(rule, input.eventType) &&
- matchesRuleMailbox(rule, input.data) &&
- matchesRuleKeyword(rule, input.data) &&
- !isInQuietHours(rule.quietHoursStart, rule.quietHoursEnd)
- );
+): Promise {
+ const records = await store.notificationRules.listByUser(userId);
+ const now = input.now ?? new Date();
+ const rules = records.map((rule) => {
+ const reasons: string[] = [];
+ if (!rule.enabled) reasons.push("disabled");
+ if (!matchesRuleTarget(rule, input)) reasons.push("target_mismatch");
+ if (!matchesRuleEvent(rule, input.eventType)) reasons.push("event_mismatch");
+ if (!matchesRuleMailbox(rule, input.data)) reasons.push("mailbox_mismatch");
+ if (!matchesRuleKeyword(rule, input.data)) reasons.push("keyword_mismatch");
+ if (isInQuietHours(rule.quietHoursStart, rule.quietHoursEnd, now, rule.quietHoursTimezone ?? "UTC")) reasons.push("quiet_hours");
+ return { id: rule.id, name: rule.name, matched: reasons.length === 0, reasons, quietHoursTimezone: rule.quietHoursTimezone ?? "UTC" };
+ });
+ const noEnabledRules = !records.some((rule) => rule.enabled && rule.target === input.target);
+ const matched = rules.some((rule) => rule.matched);
+ return { shouldSend: noEnabledRules || matched, reason: noEnabledRules ? "no_enabled_rules" : matched ? "matched" : "no_matching_rules", evaluatedAt: now.toISOString(), rules };
+}
+
+export async function shouldSendNotificationToTarget(store: AppStore, userId: string, input: {
+ data: Record; eventType: string; target: NotificationRuleTarget; targetId?: string | null;
+}) {
+ return (await evaluateNotificationRules(store, userId, input)).shouldSend;
}
diff --git a/apps/worker/src/app/services/telegram-service.ts b/apps/worker/src/app/services/telegram-service.ts
index 6b8327a..9148c01 100644
--- a/apps/worker/src/app/services/telegram-service.ts
+++ b/apps/worker/src/app/services/telegram-service.ts
@@ -273,7 +273,7 @@ export async function validateTelegramChat(context: TelegramContext, chatId: str
}
}
-export async function sendTelegramNotification(context: TelegramContext, payload: TelegramNotificationPayload) {
+export async function sendTelegramNotification(context: TelegramContext, payload: TelegramNotificationPayload, options?: { skipRuleCheck?: boolean }): Promise<{ delivered: boolean; attemptedAt: string; reason: string | null; retryAfterMs?: number; statusCode?: number }> {
const attemptedAt = new Date().toISOString();
if (!context.featureToggles.telegramEnabled) {
@@ -283,7 +283,7 @@ export async function sendTelegramNotification(context: TelegramContext, payload
const subscription = await context.store.telegram.findByUserId(payload.userId);
if (!subscription) return { delivered: false, attemptedAt, reason: "subscription_missing" };
if (!subscription.enabled) return { delivered: false, attemptedAt, reason: "subscription_paused" };
- const shouldSend = await shouldSendNotificationToTarget(context.store, payload.userId, {
+ const shouldSend = options?.skipRuleCheck || await shouldSendNotificationToTarget(context.store, payload.userId, {
data: { ...(payload.metadata ?? {}), text: payload.text },
eventType: payload.eventId,
target: "telegram",
@@ -296,6 +296,8 @@ export async function sendTelegramNotification(context: TelegramContext, payload
let delivered = false;
let reason: string | null = null;
+ let retryAfterMs: number | undefined;
+ let statusCode: number | undefined;
try {
const result = await client.sendMessage({
@@ -303,6 +305,8 @@ export async function sendTelegramNotification(context: TelegramContext, payload
text: payload.text
});
delivered = result.ok;
+ retryAfterMs = result.retryAfterMs;
+ statusCode = result.statusCode;
reason = result.ok ? null : "telegram_api_failed";
} catch {
reason = "telegram_request_failed";
@@ -318,7 +322,7 @@ export async function sendTelegramNotification(context: TelegramContext, payload
reason
});
- return { delivered, attemptedAt, reason };
+ return { delivered, attemptedAt, reason, retryAfterMs, statusCode };
}
export async function handleTelegramWebhookUpdate(context: TelegramContext, update: unknown): Promise {
diff --git a/apps/worker/src/app/services/webhook-service.ts b/apps/worker/src/app/services/webhook-service.ts
index 328bcca..0c7ca42 100644
--- a/apps/worker/src/app/services/webhook-service.ts
+++ b/apps/worker/src/app/services/webhook-service.ts
@@ -1,6 +1,11 @@
import type { AppStore, WebhookDeliveryRecord, WebhookEndpointRecord } from "../../core/bindings";
+import { readRetryAfter } from "../../shared/provider-response";
import { shouldSendNotificationToTarget } from "./notification-rule-service";
+export const WEBHOOK_TIMEOUT_MS = 10_000;
+const WEBHOOK_RESPONSE_BYTES = 16_384;
+const CHAT_TEXT_BYTES = 1800;
+
export const webhookEventIds = [
"message.received",
"message.extracted",
@@ -16,6 +21,8 @@ type WebhookDispatchPayload = {
createdAt: string;
data: Record;
deliveryId: string;
+ eventId?: string;
+ notificationTaskId?: string;
endpoint: {
id: string;
name: string;
@@ -74,6 +81,20 @@ function truncateText(value: string, maxLength = 2000) {
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
}
+function truncateUtf8(value: string, maxBytes: number) {
+ const encoder = new TextEncoder();
+ if (encoder.encode(value).byteLength <= maxBytes) return value;
+ let bytes = 0;
+ let result = "";
+ for (const character of value) {
+ const length = encoder.encode(character).byteLength;
+ if (bytes + length > maxBytes - 3) break;
+ result += character;
+ bytes += length;
+ }
+ return `${result}…`;
+}
+
function readEndpointEvents(endpoint: WebhookEndpointRecord) {
try {
const events = JSON.parse(endpoint.eventsJson);
@@ -86,7 +107,7 @@ function readEndpointEvents(endpoint: WebhookEndpointRecord) {
function parseDeliveryPayload(delivery: WebhookDeliveryRecord): WebhookDispatchPayload | null {
try {
const payload = JSON.parse(delivery.payloadJson) as WebhookDispatchPayload;
- if (payload && typeof payload.eventType === "string" && typeof payload.data === "object" && payload.data !== null) return payload;
+ if (payload && typeof payload.eventType === "string" && typeof payload.data === "object" && payload.data !== null && !Array.isArray(payload.data)) return payload;
} catch {
return null;
}
@@ -148,6 +169,7 @@ export function webhookDeliveryJson(delivery: WebhookDeliveryRecord) {
errorText: delivery.errorText,
responseText: delivery.responseText,
payload: JSON.parse(delivery.payloadJson) as WebhookDispatchPayload,
+ requestBodyText: delivery.requestBodyText ?? null,
createdAt: delivery.createdAt
};
}
@@ -163,10 +185,70 @@ const channelEventLabels: Record = {
function buildChannelText(eventType: string, data: Record) {
const label = channelEventLabels[eventType] ?? eventType;
- const parts = [data.mailboxAddress, data.subject, data.fromAddress, data.extraction]
+ const metadata = [data.mailboxAddress, data.subject, data.fromAddress]
.filter((value): value is string => typeof value === "string" && value.length > 0);
+ const extraction = data.extraction && typeof data.extraction === "object" ? data.extraction as Record : null;
+ const findings = Array.isArray(data.extractions)
+ ? data.extractions
+ : Array.isArray(extraction?.items)
+ ? extraction.items
+ : [extraction?.primary ?? data.extraction];
+ const values = findings.flatMap((finding) => {
+ if (typeof finding === "string") return [finding];
+ if (!finding || typeof finding !== "object") return [];
+ const value = "value" in finding ? finding.value : null;
+ return typeof value === "string" && value ? [value] : [];
+ });
+ const uniqueValues = [...new Set(values)];
+ // Reserve room for the primary finding and expiry before metadata; an
+ // arbitrary subject must not hide the verification code from a notification.
+ const parts = uniqueValues.slice(0, 1).map((value) => truncateUtf8(value, 500));
+ const expiresHint = data.expiresHint ?? extraction?.expiresHint;
+ if (typeof expiresHint === "string" && expiresHint) parts.push(truncateUtf8(expiresHint, 160));
+ parts.push(...metadata.map((value) => truncateUtf8(value, 250)));
+ parts.push(...uniqueValues.slice(1).map((value) => truncateUtf8(value, 500)));
const detail = parts.length > 0 ? `\n${parts.join(" · ")}` : "";
- return `【WeMail】${label}${detail}`;
+ // A conservative UTF-8 budget fits every supported chat channel, including
+ // byte-limited channels and multibyte Chinese text. Do not split code points.
+ return truncateUtf8(`【WeMail】${label}${detail}`, CHAT_TEXT_BYTES);
+}
+
+async function readBoundedResponse(response: Response) {
+ if (!response.body) return "";
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let bytesRead = 0;
+ let text = "";
+ try {
+ while (bytesRead < WEBHOOK_RESPONSE_BYTES) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const remaining = WEBHOOK_RESPONSE_BYTES - bytesRead;
+ text += decoder.decode(value.subarray(0, remaining), { stream: true });
+ bytesRead += Math.min(value.byteLength, remaining);
+ if (value.byteLength >= remaining) {
+ await reader.cancel();
+ break;
+ }
+ }
+ return text + decoder.decode();
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+function readChannelError(channel: string | null | undefined, responseText: string) {
+ if (channel === "slack") return responseText.trim() === "ok" ? null : `Slack rejected delivery: ${responseText || "empty response"}`;
+ if (channel !== "feishu" && channel !== "wecom" && channel !== "discord") return null;
+ try {
+ const payload = JSON.parse(responseText) as Record;
+ if (channel === "discord") return typeof payload.id === "string" && payload.id ? null : "Discord did not confirm a created message";
+ // Feishu's current response uses code; older bots return StatusCode.
+ const code = channel === "wecom" ? payload.errcode : (payload.code ?? payload.StatusCode);
+ return code === 0 ? null : `${channel} rejected delivery: ${responseText}`;
+ } catch {
+ return `${channel} returned an invalid acknowledgement`;
+ }
}
// Chat platforms each expect their own body shape; the generic webhook keeps
@@ -177,7 +259,7 @@ function renderChannelBody(channel: string | null | undefined, eventType: string
case "slack":
return JSON.stringify({ text: buildChannelText(eventType, data) });
case "discord":
- return JSON.stringify({ content: buildChannelText(eventType, data) });
+ return JSON.stringify({ content: buildChannelText(eventType, data), allowed_mentions: { parse: [] } });
case "feishu":
return JSON.stringify({ msg_type: "text", content: { text: buildChannelText(eventType, data) } });
case "wecom":
@@ -191,7 +273,8 @@ export async function sendWebhookEventToEndpoint(
store: AppStore,
endpoint: WebhookEndpointRecord,
eventType: string,
- data: Record
+ data: Record,
+ options?: { eventId?: string; notificationTaskId?: string }
) {
const deliveryId = crypto.randomUUID();
const createdAt = new Date().toISOString();
@@ -199,6 +282,8 @@ export async function sendWebhookEventToEndpoint(
createdAt,
data,
deliveryId,
+ eventId: options?.eventId,
+ notificationTaskId: options?.notificationTaskId,
endpoint: {
id: endpoint.id,
name: endpoint.name
@@ -212,32 +297,47 @@ export async function sendWebhookEventToEndpoint(
let statusCode: number | null = null;
let errorText: string | null = null;
let responseText: string | null = null;
+ let retryAfterMs: number | undefined;
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
try {
const signature = await signWebhookPayload(endpoint.signingSecret, body);
- const response = await fetch(endpoint.url, {
+ const targetUrl = new URL(endpoint.url);
+ // Discord's default wait=false can acknowledge an unsaved message. Ask
+ // for the created message so success means a confirmed platform receipt.
+ if (endpoint.channel === "discord") targetUrl.searchParams.set("wait", "true");
+ const response = await fetch(targetUrl.toString(), {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "WeMail-Webhook/1.0",
"x-wemail-delivery-id": deliveryId,
"x-wemail-event": eventType,
+ ...(options?.eventId ? { "x-wemail-event-id": options.eventId } : {}),
"x-wemail-signature": signature
},
- body
+ body,
+ signal: controller.signal,
+ redirect: "error"
});
statusCode = response.status;
- responseText = truncateText(await response.text().catch(() => ""));
+ retryAfterMs = readRetryAfter(response.headers.get("retry-after"));
+ const acknowledgement = await readBoundedResponse(response);
+ responseText = truncateText(acknowledgement);
if (response.ok) {
- status = "success";
+ errorText = readChannelError(endpoint.channel, acknowledgement);
+ if (!errorText) status = "success";
} else {
errorText = `HTTP ${response.status}${responseText ? `: ${responseText}` : ""}`;
}
} catch (error) {
- errorText = error instanceof Error ? error.message : "Webhook request failed";
+ errorText = controller.signal.aborted ? "Webhook delivery timed out" : error instanceof Error ? error.message : "Webhook request failed";
+ } finally {
+ clearTimeout(timeout);
}
- return store.webhookDeliveries.record({
+ const delivery = await store.webhookDeliveries.record({
id: deliveryId,
endpointId: endpoint.id,
eventType,
@@ -245,10 +345,12 @@ export async function sendWebhookEventToEndpoint(
statusCode,
durationMs: Date.now() - startedAt,
errorText,
- payloadJson: body,
+ payloadJson: JSON.stringify(payload),
+ requestBodyText: body,
responseText: responseText || null,
createdAt
});
+ return { ...delivery, retryAfterMs };
}
export async function sendWebhookEventToUser(store: AppStore, userId: string, eventType: string, data: Record) {
@@ -293,8 +395,9 @@ export async function retryWebhookDelivery(store: AppStore, userId: string, deli
if (!endpoint.enabled) throw new Error("Webhook endpoint must be enabled before retrying a delivery");
const payload = parseDeliveryPayload(delivery);
- return sendWebhookEventToEndpoint(store, endpoint, payload?.eventType ?? delivery.eventType, {
- ...(payload?.data ?? {}),
+ if (!payload) throw new Error("Original webhook event is unavailable; this legacy chat delivery cannot be replayed");
+ return sendWebhookEventToEndpoint(store, endpoint, payload.eventType, {
+ ...payload.data,
retryOfDeliveryId: delivery.id
- });
+ }, { eventId: payload.eventId });
}
diff --git a/apps/worker/src/app/use-cases/notification-use-cases.ts b/apps/worker/src/app/use-cases/notification-use-cases.ts
new file mode 100644
index 0000000..eb2a270
--- /dev/null
+++ b/apps/worker/src/app/use-cases/notification-use-cases.ts
@@ -0,0 +1,51 @@
+import type { NotificationDeliverySummary, NotificationRuleTarget } from "@wemail/shared";
+
+import type { AppBindings, AppStore } from "../../core/bindings";
+import { evaluateNotificationRules, isSupportedNotificationEvent } from "../services/notification-rule-service";
+import { replayNotificationTask } from "../services/notification-outbox-service";
+import { recordAudit } from "../services/audit-service";
+
+const targets: NotificationRuleTarget[] = ["webhook", "telegram", "slack", "discord", "feishu", "wecom"];
+
+export function parseNotificationTarget(value?: string): NotificationRuleTarget | undefined {
+ if (!value) return undefined;
+ if (!targets.includes(value as NotificationRuleTarget)) throw new Error("Unsupported notification target");
+ return value as NotificationRuleTarget;
+}
+
+export async function getNotificationStatus(store: AppStore, userId: string, options: { target?: NotificationRuleTarget; admin?: boolean } = {}) {
+ const [summary, tasks, endpoints] = await Promise.all([
+ store.notificationOutbox.summarize(options.admin ? undefined : userId, options.target),
+ options.admin ? store.notificationOutbox.listRecent(30) : store.notificationOutbox.listByUser(userId, 30, options.target),
+ options.admin ? Promise.resolve([]) : store.webhookEndpoints.listByUser(userId)
+ ]);
+ const names = new Map(endpoints.map((endpoint) => [endpoint.id, endpoint.name]));
+ const deliveries: NotificationDeliverySummary[] = tasks.map((task) => ({
+ id: task.id, eventId: task.eventId, eventType: task.eventType, target: task.target, targetId: task.targetId,
+ targetName: task.target === "telegram" ? "Telegram" : names.get(task.targetId) ?? task.target,
+ status: task.status, attempts: task.attempts, nextAttemptAt: task.nextAttemptAt, errorText: task.lastError,
+ createdAt: task.createdAt, updatedAt: task.updatedAt, expiresAt: task.expiresAt
+ }));
+ return { summary, deliveries };
+}
+
+export async function retryNotification(store: AppStore, env: AppBindings, userId: string, taskId: string) {
+ const result = await replayNotificationTask(store, env, userId, taskId);
+ if (result) await recordAudit(store, "user", userId, "notification-replay", { taskId });
+ return result ? getNotificationStatus(store, userId) : null;
+}
+
+export async function testNotificationRules(store: AppStore, userId: string, value: unknown) {
+ if (!value || typeof value !== "object") throw new Error("A notification sample is required");
+ const input = value as { target?: string; targetId?: string; eventType?: string; data?: Record; evaluatedAt?: string };
+ const target = parseNotificationTarget(input.target);
+ if (!target || !isSupportedNotificationEvent(input.eventType)) throw new Error("A supported target and event type are required");
+ const data = input.data && typeof input.data === "object" && !Array.isArray(input.data) ? input.data : {};
+ if (typeof data.mailboxId === "string" && data.mailboxId) {
+ const mailbox = await store.mailboxes.findById(data.mailboxId);
+ if (!mailbox || mailbox.userId !== userId) throw new Error("Mailbox is unavailable for this account");
+ }
+ const now = input.evaluatedAt ? new Date(input.evaluatedAt) : new Date();
+ if (!Number.isFinite(now.getTime())) throw new Error("A valid evaluation time is required");
+ return evaluateNotificationRules(store, userId, { data, target, eventType: input.eventType, targetId: input.targetId, now });
+}
diff --git a/apps/worker/src/core/bindings.ts b/apps/worker/src/core/bindings.ts
index ff37b34..3047a67 100644
--- a/apps/worker/src/core/bindings.ts
+++ b/apps/worker/src/core/bindings.ts
@@ -10,6 +10,7 @@ import type {
MessageFilter,
MessageListSummary,
NotificationRuleTarget,
+ NotificationStatusSummary,
OutboundListStatus,
OutboundListSummary,
UserRole,
@@ -23,7 +24,7 @@ export type RateLimiterBinding = {
};
export type TelegramApiClient = {
- sendMessage: (params: { chatId: string; text: string }) => Promise<{ ok: boolean }>;
+ sendMessage: (params: { chatId: string; text: string }) => Promise<{ ok: boolean; statusCode?: number; retryAfterMs?: number }>;
getChat: (params: { chatId: string }) => Promise<{ ok: boolean; description: string | null }>;
setMyCommands: (params: { commands: Array<{ command: string; description: string }> }) => Promise<{
ok: boolean;
@@ -431,6 +432,7 @@ export type WebhookDeliveryRecord = {
errorText: string | null;
payloadJson: string;
responseText: string | null;
+ requestBodyText?: string | null;
createdAt: string;
};
@@ -460,6 +462,41 @@ export type NotificationRuleRecord = {
keyword: string;
quietHoursStart: string;
quietHoursEnd: string;
+ quietHoursTimezone?: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type NotificationOutboxStatus = "pending" | "processing" | "retrying" | "succeeded" | "failed" | "suppressed";
+
+export type NotificationOutboxInput = {
+ messageId?: string | null;
+ eventId: string;
+ userId: string;
+ target: NotificationRuleTarget;
+ targetId: string;
+ eventType: string;
+ payloadJson: string;
+ expiresAt: string;
+ nextAttemptAt?: string;
+};
+
+export type NotificationOutboxRecord = {
+ messageId: string | null;
+ id: string;
+ eventId: string;
+ userId: string;
+ target: NotificationRuleTarget;
+ targetId: string;
+ eventType: string;
+ payloadJson: string;
+ status: NotificationOutboxStatus;
+ attempts: number;
+ nextAttemptAt: string;
+ lockedAt: string | null;
+ leaseToken: string | null;
+ lastError: string | null;
+ expiresAt: string;
createdAt: string;
updatedAt: string;
};
@@ -595,7 +632,7 @@ export interface AppStore {
delete: (id: string) => Promise;
};
messages: {
- create: (input: Omit) => Promise;
+ create: (input: Omit, options?: { id?: string; notificationTasks?: NotificationOutboxInput[] }) => Promise;
listForMailboxes: (query: MessageRecordListQuery) => Promise;
listByMailbox: (mailboxId: string) => Promise;
// Indexed idempotency lookup for inbound redelivery.
@@ -721,6 +758,17 @@ export interface AppStore {
) => Promise;
delete: (id: string, userId: string) => Promise;
};
+ notificationOutbox: {
+ enqueue: (input: NotificationOutboxInput) => Promise;
+ claimDue: (input: { nowIso: string; lockBeforeIso: string; limit: number; taskId?: string; userId?: string }) => Promise;
+ markSucceeded: (id: string, leaseToken: string, updatedAt?: string) => Promise;
+ markFailed: (id: string, input: { leaseToken: string; error: string; retryAt?: string; updatedAt?: string; suppressed?: boolean }) => Promise;
+ replay: (id: string, userId: string, nowIso: string) => Promise;
+ deleteExpired: (nowIso: string, legacyBeforeIso?: string) => Promise;
+ listByUser: (userId: string, limit?: number, target?: NotificationRuleTarget) => Promise;
+ listRecent: (limit?: number) => Promise;
+ summarize: (userId?: string, target?: NotificationRuleTarget) => Promise;
+ };
announcements: {
list: () => Promise;
listPage: (options: AnnouncementListOptions) => Promise;
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index 1f615da..9181324 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -1,17 +1,20 @@
import { createApp, processInboundEmail, runCleanup } from "./app/create-app";
import type { AppBindings } from "./core/bindings";
import { createD1Store } from "./infrastructure/persistence/d1";
+import { processNotificationOutbox } from "./app/services/notification-outbox-service";
const app = createApp();
export default {
fetch: app.fetch,
- async email(message: { to: string; raw: ReadableStream }, env: AppBindings) {
+ async email(message: { to: string; raw: ReadableStream }, env: AppBindings, context: ExecutionContext) {
if (!env.DB) return;
- await processInboundEmail(env, createD1Store(env.DB), message);
+ await processInboundEmail(env, createD1Store(env.DB), message, { deferNotifications: (work) => context.waitUntil(work) });
},
- async scheduled(_controller: ScheduledController, env: AppBindings) {
+ async scheduled(controller: ScheduledController, env: AppBindings) {
if (!env.DB) return;
- await runCleanup(createD1Store(env.DB), env);
+ const store = createD1Store(env.DB);
+ await processNotificationOutbox(store, env);
+ if (controller.cron === "0 * * * *") await runCleanup(store, env);
}
};
diff --git a/apps/worker/src/infrastructure/db/README.md b/apps/worker/src/infrastructure/db/README.md
index a3cb9b4..8dd48b6 100644
--- a/apps/worker/src/infrastructure/db/README.md
+++ b/apps/worker/src/infrastructure/db/README.md
@@ -7,6 +7,8 @@
- migration
- 数据库初始化脚本
+`schema.sql` 是新本地库的当前结构快照,必须与全部 migrations 执行后的列和索引保持一致。远端升级使用 migrations;新增迁移时同步快照,并验证空库初始化和旧库升级。
+
## 🚫 不放什么
- 业务流程逻辑
- 运行时请求处理
diff --git a/apps/worker/src/infrastructure/db/migrations/0024-webhook-delivery-request-body.sql b/apps/worker/src/infrastructure/db/migrations/0024-webhook-delivery-request-body.sql
new file mode 100644
index 0000000..986f0c2
--- /dev/null
+++ b/apps/worker/src/infrastructure/db/migrations/0024-webhook-delivery-request-body.sql
@@ -0,0 +1,3 @@
+-- Keep the canonical event independent from channel rendering so it can be
+-- replayed after provider failures or changes to a platform's request format.
+ALTER TABLE webhook_deliveries ADD COLUMN request_body_text TEXT;
diff --git a/apps/worker/src/infrastructure/db/migrations/0025-notification-outbox.sql b/apps/worker/src/infrastructure/db/migrations/0025-notification-outbox.sql
new file mode 100644
index 0000000..0504bbd
--- /dev/null
+++ b/apps/worker/src/infrastructure/db/migrations/0025-notification-outbox.sql
@@ -0,0 +1,34 @@
+-- Persist notification intent before external delivery. The event id makes
+-- enqueue idempotent while attempts remain separately observable.
+CREATE TABLE IF NOT EXISTS notification_outbox (
+ id TEXT PRIMARY KEY,
+ message_id TEXT,
+ event_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ target TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ status TEXT NOT NULL,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at TEXT NOT NULL,
+ locked_at TEXT,
+ lease_token TEXT,
+ last_error TEXT,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE(event_id, target, target_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_due
+ ON notification_outbox (status, next_attempt_at, locked_at);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_user_created
+ ON notification_outbox (user_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_expiry
+ ON notification_outbox (expires_at);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_message
+ ON notification_outbox (message_id);
diff --git a/apps/worker/src/infrastructure/db/migrations/0026-notification-rule-timezone.sql b/apps/worker/src/infrastructure/db/migrations/0026-notification-rule-timezone.sql
new file mode 100644
index 0000000..b3d05f6
--- /dev/null
+++ b/apps/worker/src/infrastructure/db/migrations/0026-notification-rule-timezone.sql
@@ -0,0 +1,3 @@
+-- Existing rules used UTC. Keep their interpretation stable while allowing
+-- newly configured rules to use an explicit IANA timezone.
+ALTER TABLE notification_rules ADD COLUMN quiet_hours_timezone TEXT NOT NULL DEFAULT 'UTC';
diff --git a/apps/worker/src/infrastructure/db/schema.sql b/apps/worker/src/infrastructure/db/schema.sql
index 58809d8..33ba347 100644
--- a/apps/worker/src/infrastructure/db/schema.sql
+++ b/apps/worker/src/infrastructure/db/schema.sql
@@ -1,12 +1,12 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
+ updated_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -15,7 +15,7 @@ CREATE TABLE IF NOT EXISTS auth_sessions (
user_agent TEXT,
ip_address TEXT,
expires_at TEXT NOT NULL,
- last_seen_at TEXT NOT NULL,
+ last_seen_at TEXT,
created_at TEXT NOT NULL
);
@@ -104,6 +104,7 @@ CREATE TABLE IF NOT EXISTS mail_messages (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
to_address TEXT,
+ message_id TEXT,
from_address TEXT NOT NULL,
subject TEXT NOT NULL,
preview_text TEXT NOT NULL,
@@ -121,6 +122,12 @@ CREATE INDEX IF NOT EXISTS idx_mail_messages_account_received
CREATE INDEX IF NOT EXISTS idx_mail_messages_account_attachment
ON mail_messages (account_id, attachment_count, received_at DESC);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_messages_account_message_id
+ ON mail_messages (account_id, message_id);
+
+CREATE INDEX IF NOT EXISTS idx_mail_messages_expires_at
+ ON mail_messages (expires_at, id);
+
CREATE TABLE IF NOT EXISTS mail_attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
@@ -229,6 +236,7 @@ CREATE TABLE IF NOT EXISTS webhook_endpoints (
user_id TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
+ channel TEXT,
events_json TEXT NOT NULL,
signing_secret TEXT NOT NULL,
enabled INTEGER NOT NULL,
@@ -246,6 +254,7 @@ CREATE TABLE IF NOT EXISTS webhook_deliveries (
error_text TEXT,
payload_json TEXT NOT NULL,
response_text TEXT,
+ request_body_text TEXT,
created_at TEXT NOT NULL
);
@@ -261,9 +270,43 @@ CREATE TABLE IF NOT EXISTS notification_rules (
keyword TEXT NOT NULL,
quiet_hours_start TEXT NOT NULL,
quiet_hours_end TEXT NOT NULL,
+ quiet_hours_timezone TEXT NOT NULL DEFAULT 'UTC',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+
+CREATE TABLE IF NOT EXISTS notification_outbox (
+ id TEXT PRIMARY KEY,
+ message_id TEXT,
+ event_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ target TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ payload_json TEXT NOT NULL,
+ status TEXT NOT NULL,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at TEXT NOT NULL,
+ locked_at TEXT,
+ lease_token TEXT,
+ last_error TEXT,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE(event_id, target, target_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_due
+ ON notification_outbox (status, next_attempt_at, locked_at);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_user_created
+ ON notification_outbox (user_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_expiry
+ ON notification_outbox (expires_at);
+
+CREATE INDEX IF NOT EXISTS idx_notification_outbox_message
+ ON notification_outbox (message_id);
CREATE INDEX IF NOT EXISTS idx_notification_rules_user ON notification_rules(user_id);
CREATE TABLE IF NOT EXISTS announcements (
diff --git a/apps/worker/src/infrastructure/persistence/README.md b/apps/worker/src/infrastructure/persistence/README.md
index d9c66cb..dec23ea 100644
--- a/apps/worker/src/infrastructure/persistence/README.md
+++ b/apps/worker/src/infrastructure/persistence/README.md
@@ -23,6 +23,7 @@
| `d1/mail.ts` | mailboxes / messages / attachments / outboundMessages |
| `d1/settings.ts` | settings / runtimeSettings / mailDomains / dictionaries / accountSettings / mailSettings / quotas |
| `d1/ops.ts` | audit / cleanupRuns / telegram / webhook* / notificationRules |
+| `d1/notification-outbox.ts` | 持久化通知任务、原子领取、租约、重放与保留期清理;邮件聚合通过准备语句在同一 D1 batch 中提交任务 |
| `d1/announcements.ts` | 公告聚合;SQL 过滤构造见 `announcement-sql.ts` |
新增聚合时:在 `d1/` 建模块、导出 `createAggregate(db)`,并在 `d1.ts` 组装。行映射统一放 `row-mappers.ts`。`in-memory.ts` 与 D1 实现保持接口对等,由集成测试约束。
diff --git a/apps/worker/src/infrastructure/persistence/d1.ts b/apps/worker/src/infrastructure/persistence/d1.ts
index 65a2c81..9b7085c 100644
--- a/apps/worker/src/infrastructure/persistence/d1.ts
+++ b/apps/worker/src/infrastructure/persistence/d1.ts
@@ -4,6 +4,7 @@ import type { AppStore } from "../../core/bindings";
import { createAnnouncementsAggregate } from "./d1/announcements";
import { createMailAggregate } from "./d1/mail";
import { createOpsAggregate } from "./d1/ops";
+import { createNotificationOutboxAggregate } from "./d1/notification-outbox";
import { createSettingsAggregate } from "./d1/settings";
import { createUsersAggregate } from "./d1/users";
@@ -16,6 +17,7 @@ export function createD1Store(db: D1Database): AppStore {
...createMailAggregate(db),
...createSettingsAggregate(db),
...createOpsAggregate(db),
+ ...createNotificationOutboxAggregate(db),
...createAnnouncementsAggregate(db)
};
}
diff --git a/apps/worker/src/infrastructure/persistence/d1/mail.ts b/apps/worker/src/infrastructure/persistence/d1/mail.ts
index 3c600a0..75cc418 100644
--- a/apps/worker/src/infrastructure/persistence/d1/mail.ts
+++ b/apps/worker/src/infrastructure/persistence/d1/mail.ts
@@ -8,6 +8,7 @@ import type {
OutboundMessageUsageSummary
} from "../../../core/bindings";
import { toMailboxDetailRecord, toMailboxRecord, toMessageRecord, toOutboundMessageRecord } from "./row-mappers";
+import { prepareNotificationInsert } from "./notification-outbox";
import { DAY_MS, getActiveRangeDays, getInactiveDays, getSafePage, getSafePageSize, nowIso } from "./shared";
// Cloudflare D1 caps bound parameters per query at 100. IN (...) clauses with
@@ -250,10 +251,10 @@ export function createMailAggregate(db: D1Database): MailAggregate {
}
},
messages: {
- async create(input) {
- const id = crypto.randomUUID();
+ async create(input, options) {
+ const id = options?.id ?? crypto.randomUUID();
try {
- await db
+ const messageStatement = db
.prepare(
"INSERT INTO mail_messages (id, account_id, to_address, message_id, from_address, subject, preview_text, body_text, extraction_json, oversize_status, attachment_count, received_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
@@ -271,8 +272,13 @@ export function createMailAggregate(db: D1Database): MailAggregate {
input.attachmentCount,
input.receivedAt,
input.expiresAt
- )
- .run();
+ );
+ const tasks = options?.notificationTasks ?? [];
+ if (tasks.length > 0) {
+ await db.batch([messageStatement, ...tasks.map((task) => prepareNotificationInsert(db, task))]);
+ } else {
+ await messageStatement.run();
+ }
} catch (error) {
// The (account_id, message_id) unique index is the race backstop:
// two concurrent redeliveries can both pass the pre-check, and the
@@ -451,7 +457,12 @@ export function createMailAggregate(db: D1Database): MailAggregate {
if (ids.length === 0) return;
for (const chunk of chunkForD1BindLimit(ids)) {
const placeholders = chunk.map(() => "?").join(", ");
- await db.prepare(`DELETE FROM mail_messages WHERE id IN (${placeholders})`).bind(...chunk).run();
+ const scope = JSON.stringify(chunk);
+ await db.batch([
+ db.prepare("DELETE FROM webhook_deliveries WHERE json_valid(payload_json) AND (json_extract(payload_json, '$.notificationTaskId') IN (SELECT id FROM notification_outbox WHERE message_id IN (SELECT value FROM json_each(?))) OR json_extract(payload_json, '$.data.messageId') IN (SELECT value FROM json_each(?)))").bind(scope, scope),
+ db.prepare("DELETE FROM notification_outbox WHERE message_id IN (SELECT value FROM json_each(?))").bind(scope),
+ db.prepare(`DELETE FROM mail_messages WHERE id IN (${placeholders})`).bind(...chunk)
+ ]);
}
}
},
diff --git a/apps/worker/src/infrastructure/persistence/d1/notification-outbox.ts b/apps/worker/src/infrastructure/persistence/d1/notification-outbox.ts
new file mode 100644
index 0000000..6bfce06
--- /dev/null
+++ b/apps/worker/src/infrastructure/persistence/d1/notification-outbox.ts
@@ -0,0 +1,103 @@
+import type { D1Database } from "@cloudflare/workers-types";
+import type { NotificationStatusSummary } from "@wemail/shared";
+
+import type { AppStore, NotificationOutboxInput, NotificationOutboxRecord } from "../../../core/bindings";
+import { getSafePageSize, nowIso } from "./shared";
+
+function toRecord(row: Record): NotificationOutboxRecord {
+ return {
+ messageId: row.message_id == null ? null : String(row.message_id),
+ id: String(row.id), eventId: String(row.event_id), userId: String(row.user_id),
+ target: row.target as NotificationOutboxRecord["target"], targetId: String(row.target_id),
+ eventType: String(row.event_type), payloadJson: String(row.payload_json),
+ status: row.status as NotificationOutboxRecord["status"], attempts: Number(row.attempts),
+ nextAttemptAt: String(row.next_attempt_at), lockedAt: row.locked_at == null ? null : String(row.locked_at),
+ leaseToken: row.lease_token == null ? null : String(row.lease_token),
+ lastError: row.last_error == null ? null : String(row.last_error), expiresAt: String(row.expires_at),
+ createdAt: String(row.created_at), updatedAt: String(row.updated_at)
+ };
+}
+
+export function prepareNotificationInsert(db: D1Database, input: NotificationOutboxInput) {
+ const now = nowIso();
+ return db.prepare(
+ "INSERT OR IGNORE INTO notification_outbox (id, message_id, event_id, user_id, target, target_id, event_type, payload_json, status, attempts, next_attempt_at, locked_at, lease_token, last_error, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, NULL, NULL, NULL, ?, ?, ?)"
+ ).bind(crypto.randomUUID(), input.messageId ?? null, input.eventId, input.userId, input.target, input.targetId, input.eventType,
+ input.payloadJson, input.nextAttemptAt ?? now, input.expiresAt, now, now);
+}
+
+export function createNotificationOutboxAggregate(db: D1Database): Pick {
+ return {
+ notificationOutbox: {
+ async enqueue(input) {
+ await prepareNotificationInsert(db, input).run();
+ const row = await db.prepare("SELECT * FROM notification_outbox WHERE event_id = ? AND target = ? AND target_id = ?")
+ .bind(input.eventId, input.target, input.targetId).first>();
+ if (!row) throw new Error("Notification outbox enqueue failed");
+ return toRecord(row);
+ },
+ async claimDue(input) {
+ // A single conditional UPDATE claims a bounded batch. The lease token
+ // prevents a timed-out owner from completing a later owner's attempt.
+ const leaseToken = crypto.randomUUID();
+ await db.prepare("UPDATE notification_outbox SET status = 'failed', locked_at = NULL, lease_token = NULL, last_error = 'Delivery attempt limit exceeded after interruption', updated_at = ? WHERE status = 'processing' AND attempts >= 5 AND locked_at <= ?")
+ .bind(input.nowIso, input.lockBeforeIso).run();
+ const filters = input.taskId ? " AND id = ? AND user_id = ?" : "";
+ const statement = db.prepare(`UPDATE notification_outbox SET status = 'processing', attempts = attempts + 1, locked_at = ?, lease_token = ?, updated_at = ? WHERE id IN (SELECT id FROM notification_outbox WHERE status IN ('pending', 'retrying', 'processing') AND attempts < 5 AND next_attempt_at <= ? AND expires_at > ? AND (locked_at IS NULL OR locked_at <= ?)${filters} ORDER BY next_attempt_at, created_at LIMIT ?) RETURNING *`);
+ const bindings: Array = [input.nowIso, leaseToken, input.nowIso, input.nowIso, input.nowIso, input.lockBeforeIso];
+ if (input.taskId) bindings.push(input.taskId, input.userId ?? "");
+ bindings.push(getSafePageSize(input.limit));
+ const result = await statement.bind(...bindings).all>();
+ return (result.results ?? []).map(toRecord);
+ },
+ async markSucceeded(id, leaseToken, updatedAt = nowIso()) {
+ await db.prepare("UPDATE notification_outbox SET status = 'succeeded', locked_at = NULL, lease_token = NULL, last_error = NULL, updated_at = ? WHERE id = ? AND status = 'processing' AND lease_token = ?")
+ .bind(updatedAt, id, leaseToken).run();
+ },
+ async markFailed(id, input) {
+ const status = input.suppressed ? "suppressed" : input.retryAt ? "retrying" : "failed";
+ await db.prepare("UPDATE notification_outbox SET status = CASE WHEN attempts >= 5 AND ? = 'retrying' THEN 'failed' ELSE ? END, locked_at = NULL, lease_token = NULL, last_error = ?, next_attempt_at = COALESCE(?, next_attempt_at), updated_at = ? WHERE id = ? AND status = 'processing' AND lease_token = ?")
+ .bind(status, status, input.error.slice(0, 2000), input.retryAt ?? null, input.updatedAt ?? nowIso(), id, input.leaseToken).run();
+ },
+ async replay(id, userId, now) {
+ const result = await db.prepare("UPDATE notification_outbox SET status = 'pending', attempts = 0, next_attempt_at = ?, locked_at = NULL, lease_token = NULL, updated_at = ? WHERE id = ? AND user_id = ? AND status IN ('failed', 'retrying', 'suppressed') AND expires_at > ? RETURNING *")
+ .bind(now, now, id, userId, now).all>();
+ return result.results?.[0] ? toRecord(result.results[0]) : null;
+ },
+ async deleteExpired(now, legacyBeforeIso) {
+ // Attempts can contain the same verification code as the durable task.
+ // Remove both in one transaction at the source message's retention time.
+ await db.batch([
+ db.prepare("DELETE FROM webhook_deliveries WHERE json_valid(payload_json) AND (json_extract(payload_json, '$.notificationTaskId') IN (SELECT id FROM notification_outbox WHERE expires_at <= ?) OR json_extract(payload_json, '$.data.messageId') IN (SELECT id FROM mail_messages WHERE expires_at <= ?))").bind(now, now),
+ db.prepare("DELETE FROM webhook_deliveries WHERE created_at <= ? AND CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.notificationTaskId') ELSE NULL END IS NULL").bind(legacyBeforeIso ?? ""),
+ db.prepare("DELETE FROM notification_outbox WHERE expires_at <= ?").bind(now)
+ ]);
+ },
+ async listByUser(userId, limit = 50, target) {
+ const result = await db.prepare(`SELECT * FROM notification_outbox WHERE user_id = ?${target ? " AND target = ?" : ""} ORDER BY created_at DESC, id DESC LIMIT ?`)
+ .bind(userId, ...(target ? [target] : []), getSafePageSize(limit)).all>();
+ return (result.results ?? []).map(toRecord);
+ },
+ async listRecent(limit = 30) {
+ const result = await db.prepare("SELECT * FROM notification_outbox ORDER BY created_at DESC, id DESC LIMIT ?")
+ .bind(getSafePageSize(limit)).all>();
+ return (result.results ?? []).map(toRecord);
+ },
+ async summarize(userId, target) {
+ const filters: string[] = ["expires_at > ?"];
+ const bindings: string[] = [nowIso()];
+ if (userId) { filters.push("user_id = ?"); bindings.push(userId); }
+ if (target) { filters.push("target = ?"); bindings.push(target); }
+ const rows = await db.prepare(`SELECT status, COUNT(*) AS count, MAX(updated_at) AS last_updated FROM notification_outbox WHERE ${filters.join(" AND ")} GROUP BY status`)
+ .bind(...bindings).all<{ status: keyof NotificationStatusSummary["counts"]; count: number; last_updated: string }>();
+ const counts: NotificationStatusSummary["counts"] = { pending: 0, processing: 0, retrying: 0, succeeded: 0, failed: 0, suppressed: 0 };
+ let lastSucceededAt: string | null = null;
+ for (const row of rows.results ?? []) {
+ if (row.status in counts) counts[row.status] = Number(row.count);
+ if (row.status === "succeeded") lastSucceededAt = row.last_updated;
+ }
+ return { counts, backlogCount: counts.pending + counts.processing + counts.retrying, lastSucceededAt };
+ }
+ }
+ };
+}
diff --git a/apps/worker/src/infrastructure/persistence/d1/ops.ts b/apps/worker/src/infrastructure/persistence/d1/ops.ts
index 989e5d2..81101c1 100644
--- a/apps/worker/src/infrastructure/persistence/d1/ops.ts
+++ b/apps/worker/src/infrastructure/persistence/d1/ops.ts
@@ -186,6 +186,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
userId: row.user_id,
name: row.name,
url: row.url,
+ channel: row.channel ?? null,
eventsJson: row.events_json,
signingSecret: row.signing_secret,
enabled: toBool(row.enabled),
@@ -274,6 +275,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
errorText: row.error_text,
payloadJson: row.payload_json,
responseText: row.response_text ?? null,
+ requestBodyText: row.request_body_text ?? null,
createdAt: row.created_at
}));
},
@@ -310,6 +312,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
errorText: row.error_text,
payloadJson: row.payload_json,
responseText: row.response_text ?? null,
+ requestBodyText: row.request_body_text ?? null,
createdAt: row.created_at
})),
total: Number(totalRow?.count ?? 0),
@@ -335,6 +338,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
errorText: row.error_text,
payloadJson: row.payload_json,
responseText: row.response_text ?? null,
+ requestBodyText: row.request_body_text ?? null,
createdAt: row.created_at
};
},
@@ -343,7 +347,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
const record = { id: id ?? crypto.randomUUID(), createdAt: createdAt ?? nowIso(), ...recordInput };
await db
.prepare(
- "INSERT INTO webhook_deliveries (id, endpoint_id, event_type, status, status_code, duration_ms, error_text, payload_json, response_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ "INSERT INTO webhook_deliveries (id, endpoint_id, event_type, status, status_code, duration_ms, error_text, payload_json, response_text, request_body_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(
record.id,
@@ -355,6 +359,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
record.errorText,
record.payloadJson,
record.responseText,
+ record.requestBodyText ?? null,
record.createdAt
)
.run();
@@ -378,7 +383,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
};
await db
.prepare(
- "INSERT INTO notification_rules (id, user_id, name, enabled, target, target_id, event_types_json, mailbox_ids_json, keyword, quiet_hours_start, quiet_hours_end, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ "INSERT INTO notification_rules (id, user_id, name, enabled, target, target_id, event_types_json, mailbox_ids_json, keyword, quiet_hours_start, quiet_hours_end, quiet_hours_timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(
record.id,
@@ -392,6 +397,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
record.keyword,
record.quietHoursStart,
record.quietHoursEnd,
+ record.quietHoursTimezone ?? "UTC",
record.createdAt,
record.updatedAt
)
@@ -402,7 +408,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
const updatedAt = nowIso();
await db
.prepare(
- "UPDATE notification_rules SET name = ?, enabled = ?, target = ?, target_id = ?, event_types_json = ?, mailbox_ids_json = ?, keyword = ?, quiet_hours_start = ?, quiet_hours_end = ?, updated_at = ? WHERE id = ? AND user_id = ?"
+ "UPDATE notification_rules SET name = ?, enabled = ?, target = ?, target_id = ?, event_types_json = ?, mailbox_ids_json = ?, keyword = ?, quiet_hours_start = ?, quiet_hours_end = ?, quiet_hours_timezone = ?, updated_at = ? WHERE id = ? AND user_id = ?"
)
.bind(
input.name,
@@ -414,6 +420,7 @@ export function createOpsAggregate(db: D1Database): OpsAggregate {
input.keyword,
input.quietHoursStart,
input.quietHoursEnd,
+ input.quietHoursTimezone ?? "UTC",
updatedAt,
id,
userId
diff --git a/apps/worker/src/infrastructure/persistence/d1/row-mappers.ts b/apps/worker/src/infrastructure/persistence/d1/row-mappers.ts
index 879d7f0..b27143f 100644
--- a/apps/worker/src/infrastructure/persistence/d1/row-mappers.ts
+++ b/apps/worker/src/infrastructure/persistence/d1/row-mappers.ts
@@ -97,6 +97,7 @@ export function toNotificationRuleRecord(row: any): NotificationRuleRecord {
keyword: row.keyword,
quietHoursStart: row.quiet_hours_start,
quietHoursEnd: row.quiet_hours_end,
+ quietHoursTimezone: row.quiet_hours_timezone ?? "UTC",
createdAt: row.created_at,
updatedAt: row.updated_at
};
diff --git a/apps/worker/src/infrastructure/persistence/in-memory.ts b/apps/worker/src/infrastructure/persistence/in-memory.ts
index 6f8331d..ac8560f 100644
--- a/apps/worker/src/infrastructure/persistence/in-memory.ts
+++ b/apps/worker/src/infrastructure/persistence/in-memory.ts
@@ -29,6 +29,7 @@ import type {
MailboxDetailListQuery,
MailboxRecord,
NotificationRuleRecord,
+ NotificationOutboxRecord,
OAuthIdentityRecord,
OAuthPendingLoginRecord,
OAuthStateRecord,
@@ -230,6 +231,7 @@ export function createInMemoryStore(): AppStore {
const webhookEndpoints = new Map();
const webhookDeliveries: WebhookDeliveryRecord[] = [];
const notificationRules = new Map();
+ const notificationOutbox = new Map();
const announcements: AnnouncementRecord[] = [];
const announcementReceipts: AnnouncementReceiptRecord[] = [];
@@ -729,11 +731,26 @@ export function createInMemoryStore(): AppStore {
}
},
messages: {
- async create(input) {
+ async create(input, options) {
+ if (input.messageId) {
+ const existing = Array.from(messages.values()).find((entry) => entry.mailboxId === input.mailboxId && entry.messageId === input.messageId);
+ if (existing) return clone(existing);
+ }
const record: PersistedMessageRecord = {
- id: crypto.randomUUID(),
+ id: options?.id ?? crypto.randomUUID(),
...input
};
+ // Mirror D1's transactional write without yielding between the message
+ // and its notification intents, including duplicate-event protection.
+ for (const task of options?.notificationTasks ?? []) {
+ if (Array.from(notificationOutbox.values()).some((entry) => entry.eventId === task.eventId && entry.target === task.target && entry.targetId === task.targetId)) continue;
+ const now = nowIso();
+ const queued: NotificationOutboxRecord = {
+ id: crypto.randomUUID(), ...task, messageId: task.messageId ?? null, status: "pending", attempts: 0,
+ nextAttemptAt: task.nextAttemptAt ?? now, lockedAt: null, leaseToken: null, lastError: null, createdAt: now, updatedAt: now
+ };
+ notificationOutbox.set(queued.id, queued);
+ }
messages.set(record.id, record);
return clone(record);
},
@@ -795,6 +812,15 @@ export function createInMemoryStore(): AppStore {
return clone(limit !== undefined ? expired.slice(0, limit) : expired);
},
async deleteMany(ids) {
+ const messageIds = new Set(ids);
+ const tasks = new Set(Array.from(notificationOutbox.values()).filter((entry) => entry.messageId && messageIds.has(entry.messageId)).map((entry) => entry.id));
+ for (let index = webhookDeliveries.length - 1; index >= 0; index -= 1) {
+ try {
+ const payload = JSON.parse(webhookDeliveries[index].payloadJson);
+ if (tasks.has(payload.notificationTaskId) || messageIds.has(payload.data?.messageId)) webhookDeliveries.splice(index, 1);
+ } catch { /* Legacy records have no associated task. */ }
+ }
+ for (const id of tasks) notificationOutbox.delete(id);
for (const id of ids) messages.delete(id);
}
},
@@ -1276,6 +1302,120 @@ export function createInMemoryStore(): AppStore {
if (existing?.userId === userId) notificationRules.delete(id);
}
},
+ notificationOutbox: {
+ async enqueue(input) {
+ const existing = Array.from(notificationOutbox.values()).find(
+ (entry) => entry.eventId === input.eventId && entry.target === input.target && entry.targetId === input.targetId
+ );
+ if (existing) return clone(existing);
+ const now = nowIso();
+ const record: NotificationOutboxRecord = {
+ id: crypto.randomUUID(),
+ eventId: input.eventId,
+ messageId: input.messageId ?? null,
+ userId: input.userId,
+ target: input.target,
+ targetId: input.targetId,
+ eventType: input.eventType,
+ payloadJson: input.payloadJson,
+ status: "pending",
+ attempts: 0,
+ nextAttemptAt: input.nextAttemptAt ?? now,
+ lockedAt: null,
+ leaseToken: null,
+ lastError: null,
+ expiresAt: input.expiresAt,
+ createdAt: now,
+ updatedAt: now
+ };
+ notificationOutbox.set(record.id, record);
+ return clone(record);
+ },
+ async claimDue(input) {
+ for (const [id, entry] of notificationOutbox) {
+ if (entry.status === "processing" && entry.attempts >= 5 && entry.lockedAt && entry.lockedAt <= input.lockBeforeIso) {
+ notificationOutbox.set(id, { ...entry, status: "failed", lockedAt: null, leaseToken: null, lastError: "Delivery attempt limit exceeded after interruption", updatedAt: input.nowIso });
+ }
+ }
+ const due = Array.from(notificationOutbox.values())
+ .filter((entry) =>
+ (entry.status === "pending" || entry.status === "retrying" || entry.status === "processing") &&
+ entry.nextAttemptAt <= input.nowIso &&
+ entry.expiresAt > input.nowIso && entry.attempts < 5 &&
+ (!input.taskId || (entry.id === input.taskId && entry.userId === input.userId)) &&
+ (!entry.lockedAt || entry.lockedAt <= input.lockBeforeIso)
+ )
+ .sort((left, right) => left.nextAttemptAt.localeCompare(right.nextAttemptAt) || left.createdAt.localeCompare(right.createdAt))
+ .slice(0, input.limit);
+ const now = input.nowIso;
+ return due.map((entry) => {
+ const next = { ...entry, status: "processing" as const, attempts: entry.attempts + 1, lockedAt: now, leaseToken: crypto.randomUUID(), updatedAt: now };
+ notificationOutbox.set(entry.id, next);
+ return clone(next);
+ });
+ },
+ async markSucceeded(id, leaseToken, updatedAt = nowIso()) {
+ const entry = notificationOutbox.get(id);
+ if (!entry || entry.status !== "processing" || entry.leaseToken !== leaseToken) return;
+ notificationOutbox.set(id, { ...entry, status: "succeeded", lockedAt: null, leaseToken: null, lastError: null, updatedAt });
+ },
+ async markFailed(id, input) {
+ const entry = notificationOutbox.get(id);
+ if (!entry || entry.status !== "processing" || entry.leaseToken !== input.leaseToken) return;
+ const updatedAt = input.updatedAt ?? nowIso();
+ const permanent = entry.attempts >= 5 || !input.retryAt;
+ notificationOutbox.set(id, {
+ ...entry,
+ status: input.suppressed ? "suppressed" : permanent ? "failed" : "retrying",
+ lockedAt: null,
+ leaseToken: null,
+ lastError: input.error,
+ nextAttemptAt: input.retryAt ?? entry.nextAttemptAt,
+ updatedAt
+ });
+ },
+ async replay(id, userId, now) {
+ const entry = notificationOutbox.get(id);
+ if (!entry || entry.userId !== userId || !["retrying", "failed", "suppressed"].includes(entry.status) || entry.expiresAt <= now) return null;
+ const next = { ...entry, status: "pending" as const, attempts: 0, nextAttemptAt: now, lockedAt: null, leaseToken: null, updatedAt: now };
+ notificationOutbox.set(id, next);
+ return clone(next);
+ },
+ async deleteExpired(now, legacyBeforeIso) {
+ const expired = new Set(Array.from(notificationOutbox.values()).filter((entry) => entry.expiresAt <= now).map((entry) => entry.id));
+ for (let index = webhookDeliveries.length - 1; index >= 0; index -= 1) {
+ try {
+ const payload = JSON.parse(webhookDeliveries[index].payloadJson);
+ const message = messages.get(payload.data?.messageId);
+ if (expired.has(payload.notificationTaskId) || (message && message.expiresAt <= now) || (!payload.notificationTaskId && legacyBeforeIso && webhookDeliveries[index].createdAt <= legacyBeforeIso)) webhookDeliveries.splice(index, 1);
+ } catch {
+ if (legacyBeforeIso && webhookDeliveries[index].createdAt <= legacyBeforeIso) webhookDeliveries.splice(index, 1);
+ }
+ }
+ for (const id of expired) notificationOutbox.delete(id);
+ },
+ async listByUser(userId, limit = 50, target) {
+ return clone(
+ Array.from(notificationOutbox.values())
+ .filter((entry) => entry.userId === userId && (!target || entry.target === target))
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt))
+ .slice(0, Math.min(Math.max(limit, 1), 500))
+ );
+ },
+ async listRecent(limit = 30) {
+ return clone(Array.from(notificationOutbox.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, getSafePageSize(limit)));
+ },
+ async summarize(userId, target) {
+ const rows = Array.from(notificationOutbox.values()).filter((entry) => (!userId || entry.userId === userId) && (!target || entry.target === target) && entry.expiresAt > nowIso());
+ const counts = { pending: 0, processing: 0, retrying: 0, succeeded: 0, failed: 0, suppressed: 0 };
+ let lastSucceededAt: string | null = null;
+ for (const row of rows) {
+ counts[row.status] += 1;
+ if (row.status === "succeeded" && (!lastSucceededAt || row.updatedAt > lastSucceededAt)) lastSucceededAt = row.updatedAt;
+ }
+ return { counts, backlogCount: counts.pending + counts.processing + counts.retrying, lastSucceededAt };
+ }
+ },
announcements: {
async list() {
return clone([...announcements].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.publishedAt.localeCompare(a.publishedAt)));
diff --git a/apps/worker/src/modules/api-interface-catalog.mjs b/apps/worker/src/modules/api-interface-catalog.mjs
index c6f7083..53f892c 100644
--- a/apps/worker/src/modules/api-interface-catalog.mjs
+++ b/apps/worker/src/modules/api-interface-catalog.mjs
@@ -394,6 +394,9 @@ export const apiInterfaceGroups = [
example: { requestBody: null }
},
{ method: "GET", path: "/api/notification/rules", title: "通知规则列表", description: "读取当前用户的 Webhook/Telegram/外部目标通知规则。", access: "登录用户" },
+ { method: "GET", path: "/api/notification/deliveries", title: "通知状态", description: "读取本人通知任务、精确状态计数和最近成功,可按 target 筛选。", access: "登录用户" },
+ { method: "POST", path: "/api/notification/deliveries/:id/retry", title: "重放通知任务", description: "重试本人未过期、尚未在投递的失败或暂停任务。", access: "登录用户", example: { requestBody: null } },
+ { method: "POST", path: "/api/notification/rules/test", title: "试运行通知规则", description: "对样本事件返回匹配与抑制原因,不发送外部通知。", access: "登录用户", example: { requestBody: { target: "webhook", eventType: "message.received", data: { subject: "Verification code" } } } },
{
method: "POST",
path: "/api/notification/rules",
@@ -408,7 +411,7 @@ export const apiInterfaceGroups = [
title: "更新通知规则",
description: "替换指定通知规则的匹配条件。",
access: "登录用户",
- example: { requestBody: { name: "验证码通知", target: "telegram", eventTypes: ["message.extraction.detected"], quietHoursStart: "23:00", quietHoursEnd: "08:00" } }
+ example: { requestBody: { name: "验证码通知", target: "telegram", eventTypes: ["message.extraction.detected"], quietHoursStart: "23:00", quietHoursEnd: "08:00", quietHoursTimezone: "Asia/Shanghai" } }
},
{ method: "DELETE", path: "/api/notification/rules/:id", title: "删除通知规则", description: "删除指定通知规则。", access: "登录用户" }
]
@@ -498,6 +501,7 @@ export const apiInterfaceGroups = [
{ method: "GET", path: "/api/system/diagnostics", title: "系统诊断", description: "读取部署就绪和集成配置诊断。", access: "管理员" },
{ method: "GET", path: "/api/system/maturity", title: "产品成熟度", description: "读取 8 个成熟产品方向的进度、证据和下一步动作。", access: "管理员" },
{ method: "GET", path: "/api/system/reliability", title: "数据可靠性", description: "读取 D1/R2、migration、清理运行记录、幂等策略和备份恢复 runbook。", access: "管理员" },
+ { method: "GET", path: "/api/system/notification-status", title: "全站通知状态", description: "读取管理员可见的全站通知状态计数与最近任务元数据。", access: "管理员" },
{ method: "GET", path: "/api/system/features", title: "功能开关", description: "读取平台功能开关。", access: "管理员" },
{
method: "PATCH",
diff --git a/apps/worker/src/modules/integration-openapi.mjs b/apps/worker/src/modules/integration-openapi.mjs
index 266b1bf..0a6720b 100644
--- a/apps/worker/src/modules/integration-openapi.mjs
+++ b/apps/worker/src/modules/integration-openapi.mjs
@@ -207,7 +207,8 @@ export const paths = {
eventTypes: ["message.received"],
keyword: "code",
quietHoursStart: "23:00",
- quietHoursEnd: "08:00"
+ quietHoursEnd: "08:00",
+ quietHoursTimezone: "Asia/Shanghai"
}
}
}
@@ -233,6 +234,37 @@ export const paths = {
responses: { 200: { $ref: "#/components/responses/Ok" }, 404: { $ref: "#/components/responses/Error" } }
}
},
+ "/api/notification/deliveries": {
+ get: {
+ tags: ["Webhook"], summary: "获取本人通知任务与状态统计", operationId: "getNotificationDeliveries",
+ security: [{ cookieAuth: [] }, { bearerAuth: [] }],
+ parameters: [{ name: "target", in: "query", schema: { type: "string", enum: ["webhook", "telegram", "slack", "discord", "feishu", "wecom"] } }],
+ responses: { 200: { description: "状态统计与最近投递元数据,不含原始邮件、验证码或租约" }, 401: { $ref: "#/components/responses/Error" } }
+ }
+ },
+ "/api/notification/deliveries/{id}/retry": {
+ post: {
+ tags: ["Webhook"], summary: "重试本人通知任务", operationId: "retryNotificationDelivery",
+ security: [{ cookieAuth: [] }, { bearerAuth: [] }], parameters: [{ $ref: "#/components/parameters/IdPath" }],
+ responses: { 200: { description: "更新后的状态" }, 409: { description: "任务不可用、已过期或正在投递" } }
+ }
+ },
+ "/api/notification/rules/test": {
+ post: {
+ tags: ["Webhook"], summary: "试运行通知规则并解释结果", operationId: "testNotificationRules",
+ security: [{ cookieAuth: [] }, { bearerAuth: [] }],
+ requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["target", "eventType"], properties: {
+ target: { type: "string" }, targetId: { type: "string" }, eventType: { type: "string" }, data: { type: "object" }, evaluatedAt: { type: "string", format: "date-time" }
+ } } } } },
+ responses: { 200: { description: "shouldSend、默认策略和逐条规则匹配/抑制原因" }, 400: { $ref: "#/components/responses/Error" } }
+ }
+ },
+ "/api/system/notification-status": {
+ get: {
+ tags: ["系统设置"], summary: "管理员查看全站通知积压与结果", operationId: "getSystemNotificationStatus",
+ security: [{ cookieAuth: [] }, { bearerAuth: [] }], responses: { 200: { description: "全站状态统计与最近任务元数据" }, 403: { $ref: "#/components/responses/Error" } }
+ }
+ },
"/api/announcements": {
get: {
tags: ["公告"],
diff --git a/apps/worker/src/modules/notification-rules/routes.ts b/apps/worker/src/modules/notification-rules/routes.ts
index 1af9b4c..50ebd43 100644
--- a/apps/worker/src/modules/notification-rules/routes.ts
+++ b/apps/worker/src/modules/notification-rules/routes.ts
@@ -8,8 +8,37 @@ import {
toNotificationRuleRecordInput,
toNotificationRuleSummary
} from "../../app/services/notification-rule-service";
+import { getNotificationStatus, parseNotificationTarget, retryNotification, testNotificationRules } from "../../app/use-cases/notification-use-cases";
export function registerNotificationRuleRoutes(app: Hono) {
+ app.get("/api/notification/deliveries", async (c) => {
+ const user = requireUser(c);
+ if (!user) return jsonError("Authentication required", 401);
+ try { return c.json(await getNotificationStatus(c.get("store"), user.id, { target: parseNotificationTarget(c.req.query("target")) })); }
+ catch (error) { return jsonError(error instanceof Error ? error.message : "Unable to load notifications", 400); }
+ });
+
+ app.get("/api/system/notification-status", async (c) => {
+ const user = requireUser(c);
+ if (!user) return jsonError("Authentication required", 401);
+ if (user.role !== "admin") return jsonError("Admin role required", 403);
+ return c.json(await getNotificationStatus(c.get("store"), user.id, { admin: true }));
+ });
+
+ app.post("/api/notification/deliveries/:id/retry", async (c) => {
+ const user = requireUser(c);
+ if (!user) return jsonError("Authentication required", 401);
+ const result = await retryNotification(c.get("store"), c.env, user.id, c.req.param("id"));
+ return result ? c.json(result) : jsonError("Notification is unavailable, expired or currently delivering", 409);
+ });
+
+ app.post("/api/notification/rules/test", async (c) => {
+ const user = requireUser(c);
+ if (!user) return jsonError("Authentication required", 401);
+ try { return c.json(await testNotificationRules(c.get("store"), user.id, await c.req.json())); }
+ catch (error) { return jsonError(error instanceof Error ? error.message : "Invalid notification sample", 400); }
+ });
+
app.get("/api/notification/rules", async (c) => {
const user = requireUser(c);
if (!user) return jsonError("Authentication required", 401);
diff --git a/apps/worker/src/modules/webhook/routes.ts b/apps/worker/src/modules/webhook/routes.ts
index acf5dd7..f9ef0fd 100644
--- a/apps/worker/src/modules/webhook/routes.ts
+++ b/apps/worker/src/modules/webhook/routes.ts
@@ -5,12 +5,12 @@ import { requireUser } from "../../app/context";
import { jsonError, recordAudit } from "../../app/services/audit-service";
import {
normalizeWebhookEvents,
- retryWebhookDelivery,
sendWebhookTestEvent,
validateWebhookTargetUrl,
webhookDeliveryJson
} from "../../app/services/webhook-service";
import type { WebhookEndpointRecord } from "../../core/bindings";
+import { retryWebhookDeliveryWithOutbox } from "../../app/services/notification-outbox-service";
const webhookEndpointPageSizes = new Set([5, 10, 20, 50]);
const webhookDeliveryPageSizes = new Set([5, 10, 20, 50]);
@@ -244,7 +244,7 @@ export function registerWebhookRoutes(app: Hono) {
const user = requireUser(c);
if (!user) return jsonError("Authentication required", 401);
try {
- const delivery = await retryWebhookDelivery(c.get("store"), user.id, c.req.param("id"));
+ const delivery = await retryWebhookDeliveryWithOutbox(c.get("store"), c.env, user.id, c.req.param("id"));
if (!delivery) return jsonError("Webhook delivery not found", 404);
await recordAudit(c.get("store"), "user", user.id, "webhook-retry", { deliveryId: c.req.param("id"), retryDeliveryId: delivery.id });
return c.json({ delivery: webhookDeliveryJson(delivery) });
diff --git a/apps/worker/src/shared/mail.ts b/apps/worker/src/shared/mail.ts
index e1a3c67..94351a7 100644
--- a/apps/worker/src/shared/mail.ts
+++ b/apps/worker/src/shared/mail.ts
@@ -8,6 +8,7 @@ import {
type MessageExtraction
} from "@wemail/shared";
import type { AppBindings, AttachmentRecord, PersistedMessageRecord, ResendClient, TelegramApiClient } from "../core/bindings";
+import { readProviderBody, readRetryAfter } from "./provider-response";
const htmlEntityMap: Record = {
amp: "&",
@@ -247,16 +248,23 @@ export function buildTelegramClient(token: string | undefined): TelegramApiClien
return { ok: response.ok, description };
},
async sendMessage({ chatId, text }: { chatId: string; text: string }) {
- const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- chat_id: chatId,
- text,
- disable_web_page_preview: false
- })
- });
- return { ok: response.ok };
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10_000);
+ try {
+ const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
+ method: "POST", headers: { "content-type": "application/json" }, signal: controller.signal,
+ body: JSON.stringify({ chat_id: chatId, text: text.slice(0, 4000).replace(/[\uD800-\uDBFF]$/, ""), disable_web_page_preview: false })
+ });
+ const raw = await readProviderBody(response);
+ let payload: { ok?: boolean; error_code?: number; parameters?: { retry_after?: number } } | null = null;
+ try { payload = JSON.parse(raw); } catch { /* Malformed acknowledgements cannot prove delivery. */ }
+ const retry = payload?.parameters?.retry_after;
+ return {
+ ok: response.ok && payload?.ok === true,
+ statusCode: typeof payload?.error_code === "number" ? payload.error_code : response.status,
+ retryAfterMs: typeof retry === "number" && retry >= 0 ? Math.min(retry * 1000, 3_600_000) : readRetryAfter(response.headers.get("retry-after"))
+ };
+ } finally { clearTimeout(timeout); }
},
async setMyCommands({ commands }: { commands: Array<{ command: string; description: string }> }) {
const response = await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, {
diff --git a/apps/worker/src/shared/provider-response.ts b/apps/worker/src/shared/provider-response.ts
new file mode 100644
index 0000000..d8e1889
--- /dev/null
+++ b/apps/worker/src/shared/provider-response.ts
@@ -0,0 +1,25 @@
+export function readRetryAfter(value: string | null, now = Date.now()) {
+ if (!value) return undefined;
+ const seconds = Number(value);
+ const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : Date.parse(value) - now;
+ return Number.isFinite(delay) && delay >= 0 ? Math.min(delay, 3_600_000) : undefined;
+}
+
+export async function readProviderBody(response: Response, maxBytes = 16_384) {
+ if (!response.body) return "";
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let bytes = 0;
+ let text = "";
+ try {
+ while (bytes < maxBytes) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const remaining = maxBytes - bytes;
+ text += decoder.decode(value.subarray(0, remaining), { stream: true });
+ bytes += Math.min(value.byteLength, remaining);
+ if (value.byteLength >= remaining) { await reader.cancel(); break; }
+ }
+ return text + decoder.decode();
+ } finally { reader.releaseLock(); }
+}
diff --git a/apps/worker/tests/business/README.md b/apps/worker/tests/business/README.md
new file mode 100644
index 0000000..c72605a
--- /dev/null
+++ b/apps/worker/tests/business/README.md
@@ -0,0 +1,7 @@
+# 本地业务 E2E Worker
+
+本目录提供独立的本地测试入口:真实生产 HTTP 路由、入站处理与 D1/R2 本地绑定参与测试,外部邮件/聊天供应商由受控适配器代替。
+
+`scripts/run-business-e2e.mjs` 创建临时配置、随机驱动 token 与独立存储目录,绑定 127.0.0.1;测试结束后关闭进程并删除该临时目录。入口中的 `/__test/*` 只存在于测试 bundle,所有驱动请求都验证本地环境与 token。生产 `src/index.ts` 和 API 不增加测试入口。
+
+测试替身不代表真实 Email Routing、Resend 或聊天平台验收。staging 外部链路另按验收清单记录。
diff --git a/apps/worker/tests/business/test-entry.ts b/apps/worker/tests/business/test-entry.ts
new file mode 100644
index 0000000..4d4c444
--- /dev/null
+++ b/apps/worker/tests/business/test-entry.ts
@@ -0,0 +1,92 @@
+import productionWorker from "../../src/index";
+import { processInboundEmail } from "../../src/app/runtime";
+import { processNotificationOutbox } from "../../src/app/services/notification-outbox-service";
+import type { AppBindings } from "../../src/core/bindings";
+import { createD1Store } from "../../src/infrastructure/persistence/d1";
+
+type BusinessBindings = AppBindings & { BUSINESS_TEST_TOKEN: string };
+
+// This entry is bundled only by the local test runner. No actual external
+// request may escape the harness; production uses its unchanged fetch API.
+const providerRequests: Array<{ url: string; body: string }> = [];
+let resendFailures = 0;
+let webhookFailures = 0;
+globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(input instanceof Request ? input.url : String(input));
+ providerRequests.push({ url: url.toString(), body: String(init?.body ?? "") });
+ if (url.hostname === "api.resend.com") {
+ if (resendFailures > 0) {
+ resendFailures -= 1;
+ return Response.json({ message: "Controlled provider outage" }, { status: 503 });
+ }
+ return Response.json({ id: crypto.randomUUID() });
+ }
+ if (url.hostname === "hooks.business.test") {
+ if (webhookFailures > 0) {
+ webhookFailures -= 1;
+ return new Response("Controlled webhook outage", { status: 503 });
+ }
+ return new Response("ok");
+ }
+ throw new Error(`External network is disabled in business E2E: ${url.hostname}`);
+};
+
+export default {
+ async fetch(request: Request, env: BusinessBindings, context: ExecutionContext) {
+ const path = new URL(request.url).pathname;
+ if (!path.startsWith("/__test/")) return productionWorker.fetch(request, env, context);
+ if (env.ENVIRONMENT !== "local" || !env.BUSINESS_TEST_TOKEN || request.headers.get("authorization") !== `Bearer ${env.BUSINESS_TEST_TOKEN}`) {
+ return Response.json({ error: "Test driver authentication required" }, { status: 403 });
+ }
+ const store = createD1Store(env.DB!);
+ if (path === "/__test/seed" && request.method === "POST") {
+ await store.mailDomains.saveAll([{ domain: "example.com", allowedRoles: ["admin", "member"] }]);
+ for (let index = 0; index < 10; index += 1) {
+ await store.invites.create({ code: `BUSINESS-${index}`, createdByUserId: null });
+ }
+ // First registration intentionally bootstraps the admin; browser flows
+ // subsequently register members so ownership checks cannot be bypassed.
+ const response = await productionWorker.fetch(new Request(`${new URL(request.url).origin}/api/auth/register`, {
+ method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ email: "business-admin@example.com", password: "business-password-123", name: "Business Admin", inviteCode: "BUSINESS-0" })
+ }), env, context);
+ if (!response.ok) return response;
+ const cookie = response.headers.get("set-cookie")?.split(";")[0] ?? "";
+ const settings = await productionWorker.fetch(new Request(`${new URL(request.url).origin}/api/mail/settings`, {
+ method: "PUT", headers: { "content-type": "application/json", cookie },
+ body: JSON.stringify({ senderRules: { retryEnabled: true, retryAttempts: "1 次" } })
+ }), env, context);
+ if (!settings.ok) return settings;
+ return Response.json({ ok: true });
+ }
+ if (path === "/__test/inbound" && request.method === "POST") {
+ const payload = await request.json() as { to: string; raw: string };
+ const raw = new Blob([payload.raw]).stream();
+ const message = await processInboundEmail(env, store, { to: payload.to, raw });
+ return Response.json({ message });
+ }
+ if (path === "/__test/providers" && request.method === "POST") {
+ const payload = await request.json() as { resendFailures?: number; webhookFailures?: number };
+ resendFailures = Math.max(0, Math.min(payload.resendFailures ?? 0, 10));
+ webhookFailures = Math.max(0, Math.min(payload.webhookFailures ?? 0, 10));
+ providerRequests.length = 0;
+ return Response.json({ ok: true });
+ }
+ if (path === "/__test/providers" && request.method === "GET") return Response.json({ requests: providerRequests });
+ if (path === "/__test/dispatch" && request.method === "POST") {
+ const payload = await request.json() as { now?: string };
+ const now = payload.now ? new Date(payload.now) : new Date();
+ if (!Number.isFinite(now.getTime())) return Response.json({ error: "Invalid test clock" }, { status: 400 });
+ return Response.json(await processNotificationOutbox(store, env, { now }));
+ }
+ if (path === "/__test/quota" && request.method === "POST") {
+ const payload = await request.json() as { email: string; apiDailyLimit?: number; dailyLimit?: number };
+ const user = await store.users.findByEmail(payload.email);
+ if (!user) return Response.json({ error: "User not found" }, { status: 404 });
+ const quota = await store.quotas.getByUserId(user.id, 20, 200);
+ await store.quotas.save({ ...quota, apiCallsToday: 0, sendsToday: 0, apiDailyLimit: payload.apiDailyLimit ?? quota.apiDailyLimit, dailyLimit: payload.dailyLimit ?? quota.dailyLimit });
+ return Response.json({ ok: true });
+ }
+ return Response.json({ error: "Test driver route not found" }, { status: 404 });
+ }
+};
diff --git a/apps/worker/tests/d1-large-mailbox-scope.test.ts b/apps/worker/tests/d1-large-mailbox-scope.test.ts
index 4fe29e8..fa6db64 100644
--- a/apps/worker/tests/d1-large-mailbox-scope.test.ts
+++ b/apps/worker/tests/d1-large-mailbox-scope.test.ts
@@ -79,6 +79,7 @@ describe("D1 large mailbox scopes", () => {
run: vi.fn(async () => ({ success: true }))
};
const db = {
+ batch: vi.fn(async (statements: Array<{ run: () => Promise }>) => Promise.all(statements.map((entry) => entry.run()))),
prepare: vi.fn((sql: string) => {
inClauseLengths.push((sql.match(/\?/g) ?? []).length);
return statement;
diff --git a/apps/worker/tests/database-migrations.test.ts b/apps/worker/tests/database-migrations.test.ts
new file mode 100644
index 0000000..006ecc9
--- /dev/null
+++ b/apps/worker/tests/database-migrations.test.ts
@@ -0,0 +1,233 @@
+import { readFileSync, readdirSync } from "node:fs";
+import { DatabaseSync, type SQLInputValue } from "node:sqlite";
+
+import { describe, expect, it } from "vitest";
+
+import { createD1Store } from "../src/infrastructure/persistence/d1";
+
+const migrationDirectory = "src/infrastructure/db/migrations";
+const migrations = readdirSync(migrationDirectory).filter((name) => name.endsWith(".sql")).sort();
+
+function applyMigrations(sqlite: DatabaseSync, names = migrations) {
+ for (const name of names) sqlite.exec(readFileSync(`${migrationDirectory}/${name}`, "utf8"));
+}
+
+function databaseShape(sqlite: DatabaseSync) {
+ const tables = sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all();
+ return {
+ tables: tables.map(({ name }) => ({
+ name,
+ columns: sqlite.prepare(`PRAGMA table_info('${String(name)}')`).all()
+ .map(({ name: columnName, type, notnull, dflt_value, pk }) => ({ name: columnName, type, notnull, dflt_value, pk }))
+ .sort((first, second) => String(first.name).localeCompare(String(second.name))),
+ indexes: sqlite.prepare(`PRAGMA index_list('${String(name)}')`).all().map((index) => ({
+ name: index.name,
+ unique: index.unique,
+ columns: sqlite.prepare(`PRAGMA index_info('${String(index.name)}')`).all().map((column) => column.name)
+ })).sort((first, second) => String(first.name).localeCompare(String(second.name)))
+ })),
+ indexes: sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()
+ };
+}
+
+function createSqliteStore(sqlite: DatabaseSync) {
+ const db = {
+ async batch(statements: Array<{ all: () => Promise<{ results: unknown[] }> }>) {
+ sqlite.exec("BEGIN");
+ try {
+ const results = [];
+ for (const statement of statements) results.push(await statement.all());
+ sqlite.exec("COMMIT");
+ return results;
+ } catch (error) { sqlite.exec("ROLLBACK"); throw error; }
+ },
+ prepare(sql: string) {
+ let bindings: SQLInputValue[] = [];
+ const statement = {
+ bind(...values: SQLInputValue[]) { bindings = values; return statement; },
+ async first() { return (sqlite.prepare(sql).get(...bindings) as T | undefined) ?? null; },
+ async all() { return { results: sqlite.prepare(sql).all(...bindings) }; },
+ async run() { sqlite.prepare(sql).run(...bindings); return { success: true }; }
+ };
+ return statement;
+ }
+ } as unknown as D1Database;
+ return createD1Store(db);
+}
+
+describe("database migrations", () => {
+ it("initializes an empty database and keeps the local schema snapshot in sync", () => {
+ const migrated = new DatabaseSync(":memory:");
+ const snapshot = new DatabaseSync(":memory:");
+ try {
+ applyMigrations(migrated);
+ snapshot.exec(readFileSync("src/infrastructure/db/schema.sql", "utf8"));
+ expect(databaseShape(snapshot)).toEqual(databaseShape(migrated));
+ } finally {
+ migrated.close();
+ snapshot.close();
+ }
+ });
+
+ it("upgrades existing delivery records without losing canonical events and persists the request body separately", async () => {
+ const sqlite = new DatabaseSync(":memory:");
+ try {
+ applyMigrations(sqlite, migrations.filter((name) => name < "0024"));
+ sqlite.exec("INSERT INTO webhook_endpoints (id, user_id, name, url, events_json, signing_secret, enabled, created_at, updated_at) VALUES ('endpoint-1', 'user-1', 'Legacy', 'https://hooks.example.test', '[]', 'test-secret', 1, '2026-10-03', '2026-10-03')");
+ const legacyPayload = JSON.stringify({ eventType: "message.received", data: { subject: "Legacy subject" } });
+ sqlite.prepare("INSERT INTO webhook_deliveries (id, endpoint_id, event_type, status, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)")
+ .run("legacy-1", "endpoint-1", "message.received", "failed", legacyPayload, "2026-10-03");
+ applyMigrations(sqlite, migrations.filter((name) => name >= "0024"));
+ const store = createSqliteStore(sqlite);
+ await expect(store.webhookDeliveries.findByUser("legacy-1", "user-1")).resolves.toMatchObject({
+ payloadJson: legacyPayload, requestBodyText: null
+ });
+ await store.webhookDeliveries.record({
+ id: "new-1", endpointId: "endpoint-1", eventType: "message.received", status: "success",
+ statusCode: 200, durationMs: 10, errorText: null, payloadJson: legacyPayload,
+ requestBodyText: JSON.stringify({ text: "Legacy subject" }), responseText: "ok"
+ });
+ const delivery = await store.webhookDeliveries.findByUser("new-1", "user-1");
+ expect(delivery).toMatchObject({ payloadJson: legacyPayload, requestBodyText: '{"text":"Legacy subject"}' });
+ expect((await store.webhookDeliveries.listByUser("user-1")).find((record) => record.id === "new-1")).toEqual(delivery);
+ expect((await store.webhookDeliveries.listByUserPage("user-1", { page: 1, pageSize: 10 })).deliveries.find((record) => record.id === "new-1")).toEqual(delivery);
+ await expect(store.webhookDeliveries.findByUser("new-1", "other-user")).resolves.toBeNull();
+ } finally {
+ sqlite.close();
+ }
+ });
+
+ it("persists, claims, retries, and completes notification outbox tasks in D1", async () => {
+ const sqlite = new DatabaseSync(":memory:");
+ try {
+ applyMigrations(sqlite);
+ const store = createSqliteStore(sqlite);
+ const queued = await store.notificationOutbox.enqueue({
+ eventId: "message:db-1:received",
+ userId: "user-1",
+ target: "webhook",
+ targetId: "endpoint-1",
+ eventType: "message.received",
+ payloadJson: JSON.stringify({ data: { subject: "D1 recovery" } }),
+ nextAttemptAt: "2026-10-05T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z"
+ });
+ const duplicate = await store.notificationOutbox.enqueue({
+ eventId: "message:db-1:received",
+ userId: "user-1",
+ target: "webhook",
+ targetId: "endpoint-1",
+ eventType: "message.received",
+ payloadJson: JSON.stringify({ data: { subject: "D1 recovery" } }),
+ expiresAt: "2099-01-01T00:00:00.000Z"
+ });
+ expect(duplicate.id).toBe(queued.id);
+ const [claimed] = await store.notificationOutbox.claimDue({
+ nowIso: "2026-10-05T00:00:00.000Z",
+ lockBeforeIso: "2026-10-04T23:55:00.000Z",
+ limit: 10
+ });
+ expect(claimed).toMatchObject({ id: queued.id, status: "processing", attempts: 1 });
+ await store.notificationOutbox.markFailed(queued.id, {
+ leaseToken: claimed.leaseToken!,
+ error: "temporary provider failure",
+ retryAt: "2026-10-05T00:01:00.000Z"
+ });
+ expect(await store.notificationOutbox.listByUser("user-1")).toMatchObject([
+ expect.objectContaining({ status: "retrying", lastError: "temporary provider failure" })
+ ]);
+ const [reclaimed] = await store.notificationOutbox.claimDue({
+ nowIso: "2026-10-05T00:02:00.000Z",
+ lockBeforeIso: "2026-10-04T23:57:00.000Z",
+ limit: 10
+ });
+ expect(reclaimed.attempts).toBe(2);
+ await store.notificationOutbox.markSucceeded(queued.id, reclaimed.leaseToken!);
+ expect(await store.notificationOutbox.listByUser("user-1")).toMatchObject([
+ expect.objectContaining({ status: "succeeded", attempts: 2, lastError: null })
+ ]);
+ } finally {
+ sqlite.close();
+ }
+ });
+
+ it("rolls back a mail insert when its notification intent cannot commit", async () => {
+ const sqlite = new DatabaseSync(":memory:");
+ try {
+ applyMigrations(sqlite);
+ sqlite.exec("CREATE TRIGGER reject_notifications BEFORE INSERT ON notification_outbox BEGIN SELECT RAISE(ABORT, 'injected outbox failure'); END");
+ const store = createSqliteStore(sqlite);
+ await expect(store.messages.create({
+ mailboxId: "mailbox-1", messageId: "", fromAddress: "sender@example.net", subject: "Atomic mail",
+ previewText: "验证码 654321", bodyText: "验证码 654321", extractionJson: "{}", oversizeStatus: null,
+ attachmentCount: 0, receivedAt: new Date().toISOString(), expiresAt: "2099-01-01T00:00:00.000Z"
+ }, { id: "atomic-mail", notificationTasks: [{
+ eventId: "message:atomic-mail:received", userId: "user-1", target: "webhook", targetId: "endpoint-1", eventType: "message.received",
+ payloadJson: '{"data":{"messageId":"atomic-mail"}}', expiresAt: "2099-01-01T00:00:00.000Z"
+ }] })).rejects.toThrow("injected outbox failure");
+ expect(sqlite.prepare("SELECT COUNT(*) AS count FROM mail_messages").get()?.count).toBe(0);
+ expect(sqlite.prepare("SELECT COUNT(*) AS count FROM notification_outbox").get()?.count).toBe(0);
+ } finally { sqlite.close(); }
+ });
+
+ it("claims once under concurrency and rejects completion by an expired lease owner", async () => {
+ const sqlite = new DatabaseSync(":memory:");
+ try {
+ applyMigrations(sqlite);
+ const store = createSqliteStore(sqlite);
+ const task = await store.notificationOutbox.enqueue({
+ eventId: "concurrent-event", userId: "owner", target: "webhook", targetId: "endpoint", eventType: "message.received",
+ payloadJson: '{"data":{}}', nextAttemptAt: "2026-10-05T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z"
+ });
+ const input = { nowIso: "2026-10-05T00:01:00.000Z", lockBeforeIso: "2026-10-04T23:59:00.000Z", limit: 20 };
+ const claims = (await Promise.all([store.notificationOutbox.claimDue(input), store.notificationOutbox.claimDue(input)])).flat();
+ expect(claims).toHaveLength(1);
+ const [reclaimed] = await store.notificationOutbox.claimDue({ ...input, nowIso: "2026-10-05T00:05:00.000Z", lockBeforeIso: "2026-10-05T00:03:00.000Z" });
+ expect(reclaimed.leaseToken).not.toBe(claims[0].leaseToken);
+ await store.notificationOutbox.markSucceeded(task.id, claims[0].leaseToken!);
+ expect((await store.notificationOutbox.listByUser("owner"))[0].status).toBe("processing");
+ await store.notificationOutbox.markSucceeded(task.id, reclaimed.leaseToken!);
+ expect((await store.notificationOutbox.listByUser("owner"))[0].status).toBe("succeeded");
+ expect(await store.notificationOutbox.replay(task.id, "other-user", input.nowIso)).toBeNull();
+ } finally { sqlite.close(); }
+ });
+
+ it("counts all tasks beyond the bounded recent list and preserves UTC during rule upgrades", async () => {
+ const sqlite = new DatabaseSync(":memory:");
+ try {
+ applyMigrations(sqlite, migrations.filter((name) => name < "0026"));
+ sqlite.exec("INSERT INTO notification_rules (id, user_id, name, enabled, target, event_types_json, mailbox_ids_json, keyword, quiet_hours_start, quiet_hours_end, created_at, updated_at) VALUES ('legacy-rule', 'owner', 'Legacy UTC', 1, 'webhook', '[\"message.received\"]', '[]', '', '23:00', '07:00', '2026-10-01', '2026-10-01')");
+ applyMigrations(sqlite, migrations.filter((name) => name >= "0026"));
+ const store = createSqliteStore(sqlite);
+ expect((await store.notificationRules.listByUser("owner"))[0].quietHoursTimezone).toBe("UTC");
+ for (let index = 0; index < 35; index += 1) await store.notificationOutbox.enqueue({
+ eventId: `count-${index}`, userId: "owner", target: "webhook", targetId: "endpoint", eventType: "message.received", payloadJson: '{"data":{}}', expiresAt: "2099-01-01T00:00:00.000Z"
+ });
+ expect(await store.notificationOutbox.listByUser("owner", 30)).toHaveLength(30);
+ expect((await store.notificationOutbox.summarize("owner")).backlogCount).toBe(35);
+ expect((await store.notificationOutbox.summarize("other-user")).backlogCount).toBe(0);
+ } finally { sqlite.close(); }
+ });
+
+ it("removes retained verification payloads when a source message is deleted and expires unlinked legacy logs", async () => {
+ const sqlite = new DatabaseSync(":memory:");
+ try {
+ applyMigrations(sqlite);
+ const store = createSqliteStore(sqlite);
+ const endpoint = await store.webhookEndpoints.create({ userId: "owner", name: "Slack", url: "https://hooks.example.test", channel: "slack", eventsJson: '[]', enabled: true });
+ expect((await store.webhookEndpoints.listByUserPage("owner", { page: 1, pageSize: 5 })).endpoints[0].channel).toBe("slack");
+ const message = await store.messages.create({
+ mailboxId: "mailbox", fromAddress: "sender@example.net", subject: "Code", bodyText: "654321", previewText: "654321", extractionJson: "{}", oversizeStatus: null, attachmentCount: 0, receivedAt: new Date().toISOString(), expiresAt: "2099-01-01T00:00:00.000Z"
+ }, { id: "cleanup-message", notificationTasks: [{
+ messageId: "cleanup-message", eventId: "cleanup-event", userId: "owner", target: "slack", targetId: endpoint.id, eventType: "message.extracted", payloadJson: '{"data":{"messageId":"cleanup-message","code":"654321"}}', expiresAt: "2099-01-01T00:00:00.000Z"
+ }] });
+ const [task] = await store.notificationOutbox.listByUser("owner");
+ await store.webhookDeliveries.record({ endpointId: endpoint.id, eventType: "message.extracted", status: "failed", statusCode: 503, durationMs: 1, errorText: null, responseText: null, payloadJson: JSON.stringify({ notificationTaskId: task.id, data: { code: "654321" } }) });
+ await store.messages.deleteMany([message.id]);
+ expect(await store.notificationOutbox.listByUser("owner")).toHaveLength(0);
+ expect(await store.webhookDeliveries.listByUser("owner")).toHaveLength(0);
+ await store.webhookDeliveries.record({ endpointId: endpoint.id, eventType: "message.extracted", status: "failed", statusCode: 503, durationMs: 1, errorText: null, responseText: null, payloadJson: '{"text":"legacy 654321"}', createdAt: "2000-01-01T00:00:00.000Z" });
+ await store.notificationOutbox.deleteExpired(new Date().toISOString(), "2001-01-01T00:00:00.000Z");
+ expect(await store.webhookDeliveries.listByUser("owner")).toHaveLength(0);
+ } finally { sqlite.close(); }
+ });
+});
diff --git a/apps/worker/tests/integration/README.md b/apps/worker/tests/integration/README.md
index 6739904..e21253c 100644
--- a/apps/worker/tests/integration/README.md
+++ b/apps/worker/tests/integration/README.md
@@ -1,6 +1,6 @@
# 🔍 integration
-Worker 集成测试骨架目录。
+Worker 路由与业务协同集成测试目录。
## ✅ 放什么
- 路由级集成测试
@@ -13,7 +13,14 @@ Worker 集成测试骨架目录。
## 📍 当前状态
-当前目录先提供结构骨架,后续建议补充:
-- auth route integration
-- outbound quota integration
-- admin feature toggle integration
+当前已有 `admin`、`mailbox`、`message`、`outbound`、`settings` 五组测试,覆盖路由权限、注册/邀请码前置、邮箱管理、邮件过滤/读取/批量操作、发件额度和设置/API Key 等业务协同。
+
+`tests/helpers/test-env.ts` 创建 Hono app 和内存 store,通过 `app.request()` 调用生产路由;相关用例模拟外部 `fetch`。这些测试不启动真实 Worker、D1、Email Routing 或供应商服务,不能作为真实远端链路已验收的证据。
+
+在仓库根目录运行:
+
+```bash
+pnpm test:worker:integration
+```
+
+连接隔离本地 D1 的业务验证和 staging 外部验收另见[测试策略](../../../../docs/testing-strategy.md)。新增测试驱动、迁移验证或真实环境验证后,同步此处的范围说明。
diff --git a/apps/worker/tests/notification-outbox.test.ts b/apps/worker/tests/notification-outbox.test.ts
new file mode 100644
index 0000000..12055a5
--- /dev/null
+++ b/apps/worker/tests/notification-outbox.test.ts
@@ -0,0 +1,165 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { enqueueNotificationEvent, processNotificationOutbox } from "../src/app/services/notification-outbox-service";
+import { processInboundEmail } from "../src/app/runtime";
+import { createInMemoryStore } from "../src/infrastructure/persistence/in-memory";
+
+const featureToggles = { telegramEnabled: false } as const;
+
+async function createWebhookFixture() {
+ const store = createInMemoryStore();
+ const endpoint = await store.webhookEndpoints.create({
+ userId: "user-1",
+ name: "Recovery webhook",
+ url: "https://hooks.example.test/recovery",
+ channel: "webhook",
+ eventsJson: JSON.stringify(["message.extracted"]),
+ enabled: true
+ });
+ return { store, endpoint };
+}
+
+describe("notification outbox", () => {
+ afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
+
+ it("enqueues one durable intent per target and is idempotent for a repeated event", async () => {
+ const { store, endpoint } = await createWebhookFixture();
+ const input = {
+ store,
+ featureToggles,
+ userId: "user-1",
+ eventId: "message:1:extracted",
+ eventType: "message.extracted",
+ data: { mailboxId: "mailbox-1", extraction: { value: "654321" } },
+ telegramText: "验证码 654321"
+ };
+
+ const first = await enqueueNotificationEvent(input);
+ const second = await enqueueNotificationEvent(input);
+
+ expect(first).toHaveLength(1);
+ expect(first[0].targetId).toBe(endpoint.id);
+ expect(second[0].id).toBe(first[0].id);
+ expect(await store.notificationOutbox.listByUser("user-1")).toHaveLength(1);
+ });
+
+ it("keeps a failed delivery retryable and recovers it after the provider returns", async () => {
+ const { store } = await createWebhookFixture();
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(new Response("provider unavailable", { status: 503 }))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const [queued] = await enqueueNotificationEvent({
+ store,
+ featureToggles,
+ userId: "user-1",
+ eventId: "message:2:extracted",
+ eventType: "message.extracted",
+ data: { subject: "Verify", extraction: { value: "482914" } }
+ });
+
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session" });
+ const failed = (await store.notificationOutbox.listByUser("user-1"))[0];
+ expect(failed).toMatchObject({ id: queued.id, status: "retrying", attempts: 1 });
+ expect(failed.lastError).toMatch(/HTTP 503/);
+
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session" }, { now: new Date(failed.nextAttemptAt) });
+ expect((await store.notificationOutbox.listByUser("user-1"))[0]).toMatchObject({ status: "succeeded", attempts: 2, lastError: null });
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("marks a permanently failing target after the bounded attempt count", async () => {
+ const { store } = await createWebhookFixture();
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response("down", { status: 503 })));
+ const [queued] = await enqueueNotificationEvent({
+ store,
+ featureToggles,
+ userId: "user-1",
+ eventId: "message:3:extracted",
+ eventType: "message.extracted",
+ data: { extraction: { value: "111111" } }
+ });
+
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ const task = (await store.notificationOutbox.listByUser("user-1")).find((entry) => entry.id === queued.id)!;
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session" }, { now: new Date(task.nextAttemptAt) });
+ }
+ expect((await store.notificationOutbox.listByUser("user-1"))[0]).toMatchObject({ status: "failed", attempts: 5 });
+ });
+
+ it("respects Retry-After and treats a permanent HTTP rejection as terminal", async () => {
+ const { store } = await createWebhookFixture();
+ const now = new Date();
+ const fetchMock = vi.fn().mockResolvedValueOnce(new Response("rate limited", { status: 429, headers: { "retry-after": "120" } }))
+ .mockResolvedValueOnce(new Response("invalid target", { status: 400 }));
+ vi.stubGlobal("fetch", fetchMock);
+ await enqueueNotificationEvent({ store, featureToggles, userId: "user-1", eventId: "rate-limited", eventType: "message.extracted", data: {} });
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session" }, { now: new Date(now.getTime() + 10) });
+ const retry = (await store.notificationOutbox.listByUser("user-1"))[0];
+ expect(new Date(retry.nextAttemptAt).getTime() - now.getTime()).toBeGreaterThanOrEqual(120_000);
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session" }, { now: new Date(retry.nextAttemptAt) });
+ expect((await store.notificationOutbox.listByUser("user-1"))[0].status).toBe("failed");
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("recovers a claimed but interrupted task with the same event id", async () => {
+ const { store } = await createWebhookFixture();
+ const fetchMock = vi.fn().mockImplementation(async () => Response.json({ ok: true }));
+ vi.stubGlobal("fetch", fetchMock);
+ await enqueueNotificationEvent({ store, featureToggles, userId: "user-1", eventId: "stable-event", eventType: "message.extracted", data: { extraction: { value: "654321" } } });
+ const now = new Date();
+ const [abandoned] = await store.notificationOutbox.claimDue({ nowIso: now.toISOString(), lockBeforeIso: new Date(now.getTime() - 120_000).toISOString(), limit: 1 });
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session" }, { now: new Date(now.getTime() + 121_000) });
+ const task = (await store.notificationOutbox.listByUser("user-1"))[0];
+ expect(task).toMatchObject({ id: abandoned.id, status: "succeeded", attempts: 2 });
+ expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get("x-wemail-event-id")).toBe("stable-event");
+ });
+
+ it("attempts healthy targets even when another target fails", async () => {
+ const { store } = await createWebhookFixture();
+ await store.webhookEndpoints.create({ userId: "user-1", name: "Healthy", url: "https://hooks.example.test/healthy", channel: "webhook", eventsJson: '["message.extracted"]', enabled: true });
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url) => new Response("ok", { status: String(url).includes("healthy") ? 200 : 503 })));
+ await enqueueNotificationEvent({ store, featureToggles, userId: "user-1", eventId: "fan-out", eventType: "message.extracted", data: {} });
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session" });
+ expect((await store.notificationOutbox.listByUser("user-1")).map((entry) => entry.status).sort()).toEqual(["retrying", "succeeded"]);
+ });
+
+ it("keeps committed mail and tasks when dispatch fails, then suppresses duplicate inbound events", async () => {
+ const { store } = await createWebhookFixture();
+ const mailbox = await store.mailboxes.create({ userId: "user-1", address: "codes@example.com", label: "Codes" });
+ const claim = vi.spyOn(store.notificationOutbox, "claimDue").mockRejectedValueOnce(new Error("Injected dispatcher interruption"));
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const env = { APP_NAME: "WeMail", COOKIE_NAME: "session", ENABLE_AI: "false", ENABLE_TELEGRAM: "false" };
+ const raw = `From: sender@example.net\r\nTo: ${mailbox.address}\r\nMessage-ID: \r\nSubject: Your verification code\r\nContent-Type: text/plain\r\n\r\nYour verification code is 654321`;
+ const deferred: Promise[] = [];
+ const message = await processInboundEmail(env, store, { to: mailbox.address, raw: new Blob([raw]).stream() }, { deferNotifications: (work) => deferred.push(work) });
+ await Promise.all(deferred);
+ expect(await store.messages.findById(message.id)).not.toBeNull();
+ const [task] = await store.notificationOutbox.listByUser("user-1");
+ expect(task).toMatchObject({ messageId: message.id, status: "pending", attempts: 0, expiresAt: message.expiresAt });
+ claim.mockRestore();
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => Response.json({ ok: true })));
+ await processNotificationOutbox(store, env);
+ const repeated = await processInboundEmail(env, store, { to: mailbox.address, raw: new Blob([raw]).stream() });
+ expect(repeated.id).toBe(message.id);
+ expect(await store.notificationOutbox.listByUser("user-1")).toHaveLength(1);
+ expect((await store.notificationOutbox.listByUser("user-1"))[0].status).toBe("succeeded");
+ await store.messages.deleteMany([message.id]);
+ expect(await store.notificationOutbox.listByUser("user-1")).toHaveLength(0);
+ expect(await store.webhookDeliveries.listByUser("user-1")).toHaveLength(0);
+ });
+
+ it("delivers extracted Telegram events using the existing alias and keeps codes out of audit metadata", async () => {
+ const { store } = await createWebhookFixture();
+ await store.telegram.upsert({ userId: "user-1", chatId: "12345", enabled: true });
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => Response.json({ ok: true })));
+ const now = new Date();
+ await enqueueNotificationEvent({ store, featureToggles: { telegramEnabled: true }, userId: "user-1", eventId: "telegram-extraction", eventType: "message.extracted", data: { extraction: { value: "654321" } }, telegramText: "验证码 654321", expiresAt: new Date(now.getTime() + 60_000).toISOString() });
+ await processNotificationOutbox(store, { APP_NAME: "WeMail", COOKIE_NAME: "session", ENABLE_TELEGRAM: "true", TELEGRAM_BOT_TOKEN: "test-token" });
+ expect((await store.notificationOutbox.listByUser("user-1")).map((entry) => entry.status)).toEqual(["succeeded", "succeeded"]);
+ expect((await store.audit.listRecent()).every((entry) => !entry.payloadJson.includes("654321"))).toBe(true);
+ await store.notificationOutbox.deleteExpired(new Date(now.getTime() + 61_000).toISOString());
+ expect(await store.notificationOutbox.listByUser("user-1")).toHaveLength(0);
+ expect(await store.webhookDeliveries.listByUser("user-1")).toHaveLength(0);
+ });
+});
diff --git a/apps/worker/tests/notification-rules-evaluation.test.ts b/apps/worker/tests/notification-rules-evaluation.test.ts
new file mode 100644
index 0000000..ad12a59
--- /dev/null
+++ b/apps/worker/tests/notification-rules-evaluation.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it, vi, afterEach } from "vitest";
+
+import { evaluateNotificationRules, parseNotificationRulePayload, toNotificationRuleRecordInput } from "../src/app/services/notification-rule-service";
+import { createInMemoryStore } from "../src/infrastructure/persistence/in-memory";
+import { registerUserAndGetCookie } from "./helpers/test-env";
+
+async function createRule(timezone?: string) {
+ const store = createInMemoryStore();
+ await store.notificationRules.create(toNotificationRuleRecordInput("user-1", parseNotificationRulePayload({
+ name: "Night quiet hours", enabled: true, target: "webhook", eventTypes: ["message.received"],
+ quietHoursStart: "23:00", quietHoursEnd: "07:00", ...(timezone ? { quietHoursTimezone: timezone } : {})
+ })));
+ return store;
+}
+
+describe("notification rule evaluation", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("keeps legacy rules in UTC while interpreting explicit Shanghai cross-midnight hours", async () => {
+ const now = new Date("2026-10-06T15:30:00.000Z");
+ const sample = { target: "webhook" as const, eventType: "message.received", data: {}, now };
+ const legacy = await evaluateNotificationRules(await createRule(), "user-1", sample);
+ const local = await evaluateNotificationRules(await createRule("Asia/Shanghai"), "user-1", sample);
+ expect(legacy.shouldSend).toBe(true);
+ expect(legacy.rules[0].quietHoursTimezone).toBe("UTC");
+ expect(local.shouldSend).toBe(false);
+ expect(local.rules[0].reasons).toContain("quiet_hours");
+ });
+
+ it("uses timezone daylight-saving rules instead of a fixed UTC offset", async () => {
+ const store = await createRule("America/New_York");
+ for (const instant of ["2026-07-01T03:30:00.000Z", "2026-01-01T04:30:00.000Z"]) {
+ const result = await evaluateNotificationRules(store, "user-1", { target: "webhook", eventType: "message.received", data: {}, now: new Date(instant) });
+ expect(result.rules[0].reasons).toContain("quiet_hours");
+ }
+ expect(() => parseNotificationRulePayload({ name: "Bad timezone", target: "webhook", eventTypes: ["message.received"], quietHoursTimezone: "Invalid/Location" })).toThrow(/timezone/i);
+ });
+
+ it("explains mismatched target, event, mailbox and keyword without dispatching", async () => {
+ const store = createInMemoryStore();
+ await store.notificationRules.create(toNotificationRuleRecordInput("user-1", parseNotificationRulePayload({
+ name: "Codes only", target: "webhook", targetId: "endpoint-1", eventTypes: ["message.extracted"], mailboxIds: ["mailbox-1"], keyword: "verification"
+ })));
+ const result = await evaluateNotificationRules(store, "user-1", { target: "webhook", targetId: "endpoint-2", eventType: "message.received", data: { mailboxId: "mailbox-2", subject: "News" } });
+ expect(result.shouldSend).toBe(false);
+ expect(result.rules[0].reasons.sort()).toEqual(["event_mismatch", "keyword_mismatch", "mailbox_mismatch", "target_mismatch"]);
+ });
+
+ it("exposes only owned task metadata and protects admin aggregates and foreign replay", async () => {
+ const { app, env, store, cookie } = await registerUserAndGetCookie();
+ const user = await store.users.findByEmail("admin@example.com");
+ await store.users.updateRole(user!.id, "member");
+ const endpoint = await store.webhookEndpoints.create({ userId: user!.id, name: "Owned webhook", url: "https://hooks.example.test", enabled: true, eventsJson: '["message.received"]' });
+ const common = { target: "webhook" as const, targetId: endpoint.id, eventType: "message.received", payloadJson: '{"data":{"extraction":{"value":"654321"}}}', expiresAt: "2099-01-01T00:00:00.000Z" };
+ const owned = await store.notificationOutbox.enqueue({ ...common, eventId: "owned", userId: user!.id });
+ const foreign = await store.notificationOutbox.enqueue({ ...common, eventId: "foreign", userId: "other-user" });
+ const response = await app.request("/api/notification/deliveries", { headers: { cookie } }, env);
+ expect(response.status).toBe(200);
+ const payload = await response.json() as { summary: { backlogCount: number }; deliveries: Array<{ id: string; targetName: string }> };
+ expect(payload.summary.backlogCount).toBe(1);
+ expect(payload.deliveries.map((entry) => entry.id)).toEqual([owned.id]);
+ expect(payload.deliveries[0].targetName).toBe("Owned webhook");
+ expect(JSON.stringify(payload)).not.toContain("654321");
+ expect(JSON.stringify(payload)).not.toContain("leaseToken");
+ expect((await app.request("/api/system/notification-status", { headers: { cookie } }, env)).status).toBe(403);
+ expect((await app.request(`/api/notification/deliveries/${foreign.id}/retry`, { method: "POST", headers: { cookie } }, env)).status).toBe(409);
+ const now = new Date().toISOString();
+ const [claimed] = await store.notificationOutbox.claimDue({ nowIso: now, lockBeforeIso: now, limit: 1, taskId: owned.id, userId: user!.id });
+ await store.notificationOutbox.markFailed(owned.id, { leaseToken: claimed.leaseToken!, error: "Test outage" });
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => Response.json({ ok: true })));
+ const replay = await app.request(`/api/notification/deliveries/${owned.id}/retry`, { method: "POST", headers: { cookie } }, env);
+ expect(replay.status).toBe(200);
+ expect((await store.notificationOutbox.listByUser(user!.id))[0].status).toBe("succeeded");
+ });
+});
diff --git a/apps/worker/tests/preflight-check.test.mjs b/apps/worker/tests/preflight-check.test.mjs
index f415dfc..410fbe1 100644
--- a/apps/worker/tests/preflight-check.test.mjs
+++ b/apps/worker/tests/preflight-check.test.mjs
@@ -2,9 +2,9 @@ import { execFileSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
-import { parseTomlSubset } from "../../../scripts/preflight-check.mjs";
+import { checkEnvironment, parseTomlSubset } from "../../../scripts/preflight-check.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
@@ -74,4 +74,15 @@ describe("preflight check script", () => {
}
expect(manualFailed).toBe(true);
});
+
+ it("rejects a configuration that omits notification recovery even if hourly cleanup exists", () => {
+ const log = vi.spyOn(globalThis.console, "log").mockImplementation(() => undefined);
+ try {
+ const config = parseTomlSubset(fixtureToml);
+ expect(checkEnvironment("production", config.env.production, false)).toBe(1);
+ expect(log).toHaveBeenCalledWith(expect.stringContaining("缺少每分钟通知恢复"));
+ config.env.production.triggers.crons.push("* * * * *");
+ expect(checkEnvironment("production", config.env.production, false)).toBe(0);
+ } finally { log.mockRestore(); }
+ });
});
diff --git a/apps/worker/tests/webhook-delivery-recovery.test.ts b/apps/worker/tests/webhook-delivery-recovery.test.ts
new file mode 100644
index 0000000..eadc0b9
--- /dev/null
+++ b/apps/worker/tests/webhook-delivery-recovery.test.ts
@@ -0,0 +1,370 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ retryWebhookDelivery,
+ sendWebhookEventToEndpoint,
+ WEBHOOK_TIMEOUT_MS,
+ webhookDeliveryJson
+} from "../src/app/services/webhook-service";
+import type { WebhookChannel } from "../src/core/bindings";
+import { createInMemoryStore } from "../src/infrastructure/persistence/in-memory";
+
+const channels: WebhookChannel[] = ["webhook", "slack", "discord", "feishu", "wecom"];
+const chatChannels: WebhookChannel[] = ["slack", "discord", "feishu", "wecom"];
+const userId = "notification-owner";
+const messageData = {
+ mailboxAddress: "verification@example.com",
+ subject: "Confirm your account",
+ fromAddress: "sender@example.net",
+ extraction: { type: "auth_code", label: "验证码", value: "654321", confidence: 1 },
+ extractions: [
+ { type: "auth_code", label: "验证码", value: "654321", confidence: 1 },
+ { type: "verification_link", label: "验证链接", value: "https://example.net/verify/unique-token", confidence: 1 }
+ ],
+ expiresHint: "10 分钟内有效"
+};
+
+async function createEndpoint(channel: WebhookChannel) {
+ const store = createInMemoryStore();
+ const endpoint = await store.webhookEndpoints.create({
+ userId,
+ name: `${channel} notifications`,
+ url: `https://hooks.example.test/${channel}`,
+ channel,
+ enabled: true,
+ eventsJson: JSON.stringify(["message.extracted"])
+ });
+ return { store, endpoint };
+}
+
+function successResponse(channel: WebhookChannel) {
+ if (channel === "slack") return new Response("ok", { status: 200 });
+ if (channel === "discord") return Response.json({ id: "discord-message-1" });
+ if (channel === "feishu") return Response.json({ code: 0, msg: "success" });
+ if (channel === "wecom") return Response.json({ errcode: 0, errmsg: "ok" });
+ return new Response(null, { status: 204 });
+}
+
+function chatText(channel: WebhookChannel, bodyText: string) {
+ const body = JSON.parse(bodyText);
+ if (channel === "slack") return body.text as string;
+ if (channel === "discord") return body.content as string;
+ if (channel === "feishu") return body.content.text as string;
+ if (channel === "wecom") return body.text.content as string;
+ throw new Error("Expected a chat notification body");
+}
+
+function legacyChatBody(channel: WebhookChannel) {
+ const text = "【WeMail】提取结果\nverification@example.com · 654321";
+ if (channel === "slack") return JSON.stringify({ text });
+ if (channel === "discord") return JSON.stringify({ content: text });
+ if (channel === "feishu") return JSON.stringify({ msg_type: "text", content: { text } });
+ return JSON.stringify({ msgtype: "text", text: { content: text } });
+}
+
+describe("webhook delivery recovery", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it.each(channels)("preserves the original extraction event when a failed %s notification is retried", async (channel) => {
+ const { store, endpoint } = await createEndpoint(channel);
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response("temporarily unavailable", { status: 503 }))
+ .mockResolvedValueOnce(successResponse(channel));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const failedDelivery = await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", messageData);
+ expect(failedDelivery.status).toBe("failed");
+ expect(failedDelivery.errorText).toContain("HTTP 503");
+
+ const retry = await retryWebhookDelivery(store, userId, failedDelivery.id);
+ expect(retry).toMatchObject({ endpointId: endpoint.id, eventType: "message.extracted", status: "success" });
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ if (channel === "discord") {
+ expect(new URL(String(fetchMock.mock.calls[1][0])).searchParams.get("wait")).toBe("true");
+ expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body)).allowed_mentions).toEqual({ parse: [] });
+ }
+ expect(retry?.id).not.toBe(failedDelivery.id);
+
+ const originalPayload = JSON.parse(failedDelivery.payloadJson);
+ const retryPayload = JSON.parse(retry!.payloadJson);
+ expect(originalPayload).toMatchObject({
+ deliveryId: failedDelivery.id,
+ endpoint: { id: endpoint.id, name: endpoint.name },
+ eventType: "message.extracted",
+ data: messageData
+ });
+ expect(retryPayload).toMatchObject({
+ deliveryId: retry!.id,
+ eventType: "message.extracted",
+ data: { ...messageData, retryOfDeliveryId: failedDelivery.id }
+ });
+
+ const originalBody = fetchMock.mock.calls[0][1]?.body as string;
+ const retryBody = fetchMock.mock.calls[1][1]?.body as string;
+ expect(failedDelivery.requestBodyText).toBe(originalBody);
+ expect(retry?.requestBodyText).toBe(retryBody);
+ expect(webhookDeliveryJson(retry!)).toMatchObject({ payload: retryPayload, requestBodyText: retryBody });
+ const signingKey = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(endpoint.signingSecret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["verify"]
+ );
+ for (const [index, delivery] of [failedDelivery, retry!].entries()) {
+ const request = fetchMock.mock.calls[index][1]!;
+ const headers = new Headers(request.headers);
+ const signature = headers.get("x-wemail-signature")!;
+ expect(headers.get("x-wemail-delivery-id")).toBe(delivery.id);
+ expect(headers.get("x-wemail-event")).toBe("message.extracted");
+ expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/);
+ const signatureBytes = Uint8Array.from(signature.slice(7).match(/.{2}/g)!, (byte) => Number.parseInt(byte, 16));
+ expect(await crypto.subtle.verify("HMAC", signingKey, signatureBytes, new TextEncoder().encode(request.body as string))).toBe(true);
+ }
+
+ if (channel === "webhook") {
+ expect(JSON.parse(retryBody)).toEqual(retryPayload);
+ } else {
+ const text = chatText(channel, retryBody);
+ expect(text).toBe(chatText(channel, originalBody));
+ expect(text).toContain(messageData.mailboxAddress);
+ expect(text).toContain(messageData.subject);
+ expect(text).toContain(messageData.fromAddress);
+ expect(text).toContain(messageData.extraction.value);
+ expect(text).toContain(messageData.extractions[1].value);
+ expect(text).toContain(messageData.expiresHint);
+ expect(text).not.toContain("[object Object]");
+ }
+ });
+
+ it("replays a legacy generic record containing the canonical event without a request-body snapshot", async () => {
+ const { store, endpoint } = await createEndpoint("webhook");
+ const legacy = await store.webhookDeliveries.record({
+ id: "legacy-generic-delivery",
+ endpointId: endpoint.id,
+ eventType: "message.extracted",
+ status: "failed",
+ statusCode: 503,
+ durationMs: 4,
+ errorText: "HTTP 503",
+ payloadJson: JSON.stringify({
+ deliveryId: "legacy-generic-delivery",
+ createdAt: "2026-10-01T00:00:00.000Z",
+ endpoint: { id: endpoint.id, name: endpoint.name },
+ eventType: "message.extracted",
+ data: messageData
+ }),
+ responseText: "unavailable"
+ });
+ const fetchMock = vi.fn().mockResolvedValue(successResponse("webhook"));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const retry = await retryWebhookDelivery(store, userId, legacy.id);
+
+ expect(retry?.status).toBe("success");
+ expect(JSON.parse(fetchMock.mock.calls[0][1]?.body as string)).toMatchObject({
+ eventType: "message.extracted",
+ data: { ...messageData, retryOfDeliveryId: legacy.id }
+ });
+ });
+
+ it.each(chatChannels)("rejects a legacy %s body that no longer contains its original event", async (channel) => {
+ const { store, endpoint } = await createEndpoint(channel);
+ const legacy = await store.webhookDeliveries.record({
+ endpointId: endpoint.id,
+ eventType: "message.extracted",
+ status: "failed",
+ statusCode: 503,
+ durationMs: 4,
+ errorText: "HTTP 503",
+ payloadJson: legacyChatBody(channel),
+ responseText: "unavailable"
+ });
+ const fetchMock = vi.fn().mockResolvedValue(successResponse(channel));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(retryWebhookDelivery(store, userId, legacy.id)).rejects.toThrow(/original webhook event.*unavailable|cannot be replayed/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(await store.webhookDeliveries.listByUser(userId)).toHaveLength(1);
+ });
+
+ it("does not read or replay a delivery for another user", async () => {
+ const { store, endpoint } = await createEndpoint("webhook");
+ const fetchMock = vi.fn().mockResolvedValue(new Response("unavailable", { status: 503 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const failedDelivery = await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", messageData);
+ fetchMock.mockClear();
+
+ expect(await store.webhookDeliveries.findByUser(failedDelivery.id, "another-user")).toBeNull();
+ expect(await store.webhookDeliveries.listByUser("another-user")).toEqual([]);
+ expect(await retryWebhookDelivery(store, "another-user", failedDelivery.id)).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(await store.webhookDeliveries.listByUser(userId)).toHaveLength(1);
+ });
+
+ it("does not replay a delivery after its endpoint has been disabled", async () => {
+ const { store, endpoint } = await createEndpoint("webhook");
+ const fetchMock = vi.fn().mockResolvedValue(new Response("unavailable", { status: 503 }));
+ vi.stubGlobal("fetch", fetchMock);
+ const failedDelivery = await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", messageData);
+ fetchMock.mockClear();
+ await store.webhookEndpoints.update(endpoint.id, userId, { ...endpoint, enabled: false });
+
+ await expect(retryWebhookDelivery(store, userId, failedDelivery.id)).rejects.toThrow(/must be enabled/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(await store.webhookDeliveries.listByUser(userId)).toHaveLength(1);
+ });
+
+ it.each(chatChannels)("renders an extraction envelope and expiry in a %s notification", async (channel) => {
+ const { store, endpoint } = await createEndpoint(channel);
+ const fetchMock = vi.fn().mockResolvedValue(successResponse(channel));
+ vi.stubGlobal("fetch", fetchMock);
+ const envelopeData = {
+ mailboxAddress: messageData.mailboxAddress,
+ extraction: {
+ primary: messageData.extraction,
+ items: messageData.extractions,
+ expiresHint: messageData.expiresHint
+ }
+ };
+
+ const delivery = await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", envelopeData);
+
+ expect(delivery.status).toBe("success");
+ const text = chatText(channel, fetchMock.mock.calls[0][1]?.body as string);
+ expect(text).toContain(messageData.extraction.value);
+ expect(text).toContain(messageData.expiresHint);
+ expect(text).not.toContain("[object Object]");
+ expect(JSON.parse(delivery.payloadJson).data).toEqual(envelopeData);
+ });
+
+ it.each(chatChannels)("keeps legacy string extraction values visible in a %s notification", async (channel) => {
+ const { store, endpoint } = await createEndpoint(channel);
+ const fetchMock = vi.fn().mockResolvedValue(successResponse(channel));
+ vi.stubGlobal("fetch", fetchMock);
+
+ await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", { extraction: "legacy-token" });
+
+ expect(chatText(channel, fetchMock.mock.calls[0][1]?.body as string)).toContain("legacy-token");
+ });
+
+ it.each(chatChannels)("bounds long %s messages by UTF-8 bytes while preserving the original event", async (channel) => {
+ const { store, endpoint } = await createEndpoint(channel);
+ const fetchMock = vi.fn().mockResolvedValue(successResponse(channel));
+ vi.stubGlobal("fetch", fetchMock);
+ const longData = { ...messageData, subject: "邮件主题🙂".repeat(2000) };
+
+ const delivery = await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", longData);
+
+ const text = chatText(channel, fetchMock.mock.calls[0][1]?.body as string);
+ expect(new TextEncoder().encode(text).byteLength).toBeLessThanOrEqual(1800);
+ expect(text).toContain("【WeMail】提取结果");
+ expect(text).toContain("邮件主题🙂");
+ expect(text).toContain(messageData.extraction.value);
+ expect(text).toContain(messageData.expiresHint);
+ expect(text).not.toContain("\uFFFD");
+ expect(text).toContain("…");
+ expect(JSON.parse(delivery.payloadJson).data).toEqual(longData);
+ });
+
+ it.each([
+ { channel: "slack" as const, responseText: "invalid_payload", error: /slack|invalid_payload/i },
+ { channel: "discord" as const, responseText: JSON.stringify({ code: 50035, message: "Invalid Form Body" }), error: /discord|confirm/i },
+ { channel: "feishu" as const, responseText: JSON.stringify({ code: 19001, msg: "param invalid" }), error: /19001|param invalid/i },
+ { channel: "wecom" as const, responseText: JSON.stringify({ errcode: 93000, errmsg: "invalid webhook key" }), error: /93000|invalid webhook key/i }
+ ])("records an HTTP 200 $channel API rejection as a failed delivery", async ({ channel, responseText, error }) => {
+ const { store, endpoint } = await createEndpoint(channel);
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(responseText, { status: 200 })));
+
+ const delivery = await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", messageData);
+
+ expect(delivery).toMatchObject({ status: "failed", statusCode: 200, responseText });
+ expect(delivery.errorText).toMatch(error);
+ });
+
+ it("stops reading and cancels an oversized error response before storing a bounded diagnostic", async () => {
+ const { store, endpoint } = await createEndpoint("webhook");
+ const handleCancel = vi.fn();
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(`upstream-error ${"x".repeat(20_000)} unexpected-tail`));
+ },
+ cancel: handleCancel
+ });
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body, { status: 503 })));
+
+ const delivery = await sendWebhookEventToEndpoint(store, endpoint, "message.extracted", messageData);
+
+ expect(delivery).toMatchObject({ status: "failed", statusCode: 503 });
+ expect(handleCancel).toHaveBeenCalledOnce();
+ expect(delivery.responseText).toMatch(/^upstream-error /);
+ expect(delivery.responseText!.length).toBeLessThanOrEqual(2003);
+ expect(delivery.responseText).not.toContain("unexpected-tail");
+ expect(delivery.errorText).toContain("HTTP 503");
+ expect(delivery.errorText).not.toContain("unexpected-tail");
+ });
+
+ it("aborts and records a timed-out request instead of leaving notification delivery pending", async () => {
+ vi.useFakeTimers();
+ const { store, endpoint } = await createEndpoint("webhook");
+ let handleFetchStarted!: () => void;
+ const fetchStarted = new Promise((resolve) => { handleFetchStarted = resolve; });
+ const fetchMock = vi.fn().mockImplementation((_input, init) => {
+ handleFetchStarted();
+ if (!init?.signal) return Promise.reject(new Error("Fetch requires an abort signal"));
+ return new Promise((_resolve, reject) => {
+ init.signal!.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
+ });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const pendingDelivery = sendWebhookEventToEndpoint(store, endpoint, "message.extracted", messageData);
+ await fetchStarted;
+ await vi.advanceTimersByTimeAsync(WEBHOOK_TIMEOUT_MS);
+ const delivery = await pendingDelivery;
+
+ expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true);
+ expect(delivery).toMatchObject({ status: "failed", statusCode: null });
+ expect(delivery.errorText).toMatch(/timed out|timeout/i);
+ expect(delivery.durationMs).toBe(WEBHOOK_TIMEOUT_MS);
+ expect(await store.webhookDeliveries.findByUser(delivery.id, userId)).toMatchObject({ status: "failed" });
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("keeps the timeout active while waiting for the response body and releases the reader after abort", async () => {
+ vi.useFakeTimers();
+ const { store, endpoint } = await createEndpoint("webhook");
+ let handleBodyReadStarted!: () => void;
+ const bodyReadStarted = new Promise((resolve) => { handleBodyReadStarted = resolve; });
+ let response!: Response;
+ const fetchMock = vi.fn().mockImplementation(async (_input, init) => {
+ const body = new ReadableStream({
+ start(controller) {
+ init?.signal?.addEventListener("abort", () => controller.error(new DOMException("Aborted", "AbortError")), { once: true });
+ },
+ pull() {
+ handleBodyReadStarted();
+ return new Promise(() => {});
+ }
+ });
+ response = new Response(body, { status: 200 });
+ return response;
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const pendingDelivery = sendWebhookEventToEndpoint(store, endpoint, "message.extracted", messageData);
+ await bodyReadStarted;
+ await vi.advanceTimersByTimeAsync(WEBHOOK_TIMEOUT_MS);
+ const delivery = await pendingDelivery;
+
+ expect(delivery).toMatchObject({ status: "failed", statusCode: 200 });
+ expect(delivery.errorText).toMatch(/timed out|timeout/i);
+ expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true);
+ expect(response.body?.locked).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/apps/worker/wrangler.toml b/apps/worker/wrangler.toml
index e3a8323..73fc869 100644
--- a/apps/worker/wrangler.toml
+++ b/apps/worker/wrangler.toml
@@ -66,7 +66,7 @@ simple = { limit = 60, period = 60 }
[env.staging.triggers]
# Drives the `scheduled` handler (runCleanup) that expires messages and
# soft-deleted mailboxes. Hourly keeps retention granularity without stressing D1.
-crons = ["0 * * * *"]
+crons = ["* * * * *", "0 * * * *"]
[env.production]
workers_dev = true
@@ -108,4 +108,4 @@ simple = { limit = 60, period = 60 }
[env.production.triggers]
# Drives the `scheduled` handler (runCleanup) that expires messages and
# soft-deleted mailboxes. Hourly keeps retention granularity without stressing D1.
-crons = ["0 * * * *"]
+crons = ["* * * * *", "0 * * * *"]
diff --git a/docs/README.md b/docs/README.md
index 7852c07..d4cc27c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -23,8 +23,10 @@
### 开发与质量
- `development-workflow.md`
- `testing-strategy.md`
+- `staging-validation.md`:真实邮件与通知外部链路验收步骤、记录与放行证据
- `api-guide.md`
- `openapi.yaml`
+- `plans/2026-10-03-next-development-roadmap.md`:基于 0.4.0 代码现状的下一步开发计划、优先级与验收标准
### 发布与运维
- `deploy-runbook.md`
diff --git a/docs/adr/0003-durable-notification-outbox.md b/docs/adr/0003-durable-notification-outbox.md
new file mode 100644
index 0000000..3631751
--- /dev/null
+++ b/docs/adr/0003-durable-notification-outbox.md
@@ -0,0 +1,39 @@
+# 0003:入站邮件的持久化通知任务
+
+日期:2026-10-06。状态:采用;对应 DEV-04。
+
+## 问题
+
+原流程先保存邮件,再调用 Telegram/Webhook。保存后进程中断时,重投邮件会被 Message-ID 幂等检查抑制,尚未发送的通知无法恢复。只在邮件保存之后插入任务仍存在同样的中断窗口。
+
+## 决策
+
+应用层在保存前根据用户目标和通知规则准备任务快照。邮件元数据与任务通过一次 D1 `batch` 提交;其中任一写入失败会回滚整批。内存 store 保持对应行为。并发入站的 Message-ID 唯一索引仍是最后一道保护,失败的重复事务不会留下额外任务。
+
+首次尝试由 Email handler 的 `waitUntil` 驱动,保存后的调度故障留下待处理任务。每分钟 Cron 从 D1 恢复到期任务;每小时保留期清理单独执行。投递每批最多 20 项、四项并行,各外部请求限制为 10 秒。任务使用两分钟租约、独立的随机租约 token;领取通过单个有条件的 UPDATE 完成,过期执行器无法覆盖新执行器的结果。
+
+任务按事件 ID、渠道和目标唯一。事件 ID 在重试中保持不变,尝试 ID 每次生成,通用 Webhook 带 `x-wemail-event-id` 供接收方去重。网络故障、HTTP 408/429/5xx 使用 30 秒起的指数退避,尊重有上限的 Retry-After,最多五次尝试。永久错误进入失败终态;已停用或变化的目标进入 suppressed。手动重放只允许资源所属用户操作未过期、未在投递的任务,复用相同状态机。
+
+## Cron 与 Queues 的评估
+
+选择 D1 + Cron 作为当前自托管默认路径:复用已有 D1 与 Worker,不要求新增队列资源、消费者绑定和单独配置。代价是每分钟调度、查询与状态写入,以及后台恢复最多约一分钟的调度粒度;不能将这些资源消耗描述为免费或无上限。
+
+Queues 可提供更及时的唤醒与更高的批量吞吐,但需要新增部署资源和绑定,并按其操作量/套餐计费。若待处理量持续积压,可将 Queues 加为唤醒与分发机制;D1 仍作为任务事实源,保留事务、去重和租约约束。这次不引入双写 D1/Queues 的一致性窗口。
+
+## 数据与投递边界
+
+- 邮件元数据和通知意图的提交具有事务边界,R2 文件上传不属于 D1 事务。
+- 外部投递采用至少一次语义;供应商受理后本地结果写入前中断,可能再次投递。不能声称端到端 exactly-once。
+- 任务保存必要的事件内容,其到期时间与源邮件相同。过期任务和相关 Webhook 尝试一起清理;显式删除邮件也清理其任务与尝试。Telegram 审计不复制验证码或原始邮件内容。
+- 既有邮件不自动补发历史通知,避免升级造成历史消息群发。旧通用 Webhook 记录继续可手动重放;缺少原始事件的旧聊天记录维持明确错误。
+
+## 验证
+
+覆盖事务内任务写入故障后的回滚、并发领取、过期租约接管、旧租约结果拒绝、派发中断后恢复、重复入站去重、限流退避、永久失败、独立目标投递、Telegram 事件别名、敏感内容过期/删除与手动重放。真实本地业务 E2E 验证 D1/R2 与生产路由;外部平台仍需 staging 验收。
+
+## 依据
+
+- [D1 batch 的事务和回滚契约](https://developers.cloudflare.com/d1/worker-api/d1-database/)
+- [Cloudflare Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/)
+- [Queues 计费](https://developers.cloudflare.com/queues/platform/pricing/)
+- [Email handler 与执行上下文](https://developers.cloudflare.com/email-service/api/route-emails/email-handler/)
diff --git a/docs/api-guide.md b/docs/api-guide.md
index a303bef..48a4c62 100644
--- a/docs/api-guide.md
+++ b/docs/api-guide.md
@@ -34,6 +34,11 @@ WeMail 后端 API 已按管理后台左侧菜单分组,旧 `/auth`、`/admin`
- 发件记录支持服务端分页、搜索和 `all/sent/failed` 状态筛选;详情接口会返回正文、实际发给 provider 的请求 payload、provider 响应和 message id,便于审计。
- 设置和治理数据按菜单拆分到 `account_settings`、`mail_settings`、`webhook_*`、`announcements`、`system_settings`。
- Webhook 端点支持 `channel` 字段:`webhook`(默认,投递完整 JSON 事件包并带签名头)、`slack` / `discord` / `feishu` / `wecom`(按对应平台的传入式 Webhook 消息格式投递)。通知规则可以按渠道定向(`target` 取渠道名)。
+- 投递记录的 `payload` 保留原始事件,`requestBodyText` 保留发送给平台的请求体。重试从原始事件重新生成平台消息;旧通用 JSON 记录继续可重试,缺少原始事件的旧聊天记录会明确报错,需要重新发起事件。所有渠道都有 10 秒超时;Slack、飞书、企业微信还校验平台业务响应。签名仍对实际发送的请求体计算。
+- Discord 使用 `wait=true` 并核对创建的消息 ID,禁用邮件文本触发的自动 mentions。聊天文本按 UTF-8 字节截断并为主提取值与有效期保留空间;完整原始数据仍保存在事件记录中。
+- 入站通知任务与邮件元数据在同一 D1 事务提交,失败由每分钟 cron 恢复,最多自动尝试五次。`GET /api/notification/deliveries` 返回本人状态统计与最近任务元数据,可按 `target` 筛选;`POST /api/notification/deliveries/{id}/retry` 重放本人未过期的失败/等待重试/暂停任务。管理员通过 `GET /api/system/notification-status` 查看全站统计。
+- `POST /api/notification/rules/test` 对样本事件返回 `shouldSend` 与逐条匹配/抑制原因,不发送通知。规则 `quietHoursTimezone` 使用 IANA 时区;既有或旧客户端未指定时区的规则继续按 UTC 解释,支持跨午夜免打扰。
+- 调整旧规则时,用 `PUT /api/notification/rules/{id}` 提交原有完整规则并增加 `quietHoursTimezone`;仅新增时区字段不会改变原先的邮箱、关键词或目标条件。自动重试最多五次,用户手动重放会开启新一轮计数,事件 ID 保持不变。
- 邮件详情的 `extraction` 是最佳单项发现(兼容字段);`extractions` 返回全部发现(验证码、各分类链接),`expiresHint` 为验证码有效期提示,`authSummary` 为 SPF/DKIM/DMARC 判定。提取支持中文验证码关键词与 List-Unsubscribe 头。
## 常用流程
diff --git a/docs/backup-restore-runbook.md b/docs/backup-restore-runbook.md
index 015c551..370290c 100644
--- a/docs/backup-restore-runbook.md
+++ b/docs/backup-restore-runbook.md
@@ -1,86 +1,133 @@
# 备份与恢复 Runbook
-本 runbook 覆盖 WeMail 自托管部署的数据备份、恢复与恢复后验证。所有命令均在本地
-环境完成过一次真实演练(导出 → 全新库恢复 → 校验行数与索引),演练记录见文末。
+本 runbook 覆盖 WeMail 自托管部署的数据备份、恢复与恢复后验证。文末保留一次历史本地演练记录;它不代表当前版本、远端 D1、R2 或生产切库已完成演练。每次涉及 schema 的发布都需要补充当次恢复证据。
-> 适用对象:独立部署 WeMail 的运维者。生产环境的数据库与桶名以 `wrangler.toml`
-> 各 `env.*` 段的实际配置为准。
+适用对象:独立部署 WeMail 的运维者。下文以 production 为例;staging 使用独立资源、GitHub Environment 和 `--env staging`。操作前确认账号、数据库 ID、桶名、备份时间和待恢复 commit。
## 1. 数据面盘点
| 数据 | 位置 | 保留策略 | 备份必要性 |
| --- | --- | --- | --- |
-| 用户、邮箱、消息元数据、设置、审计 | D1(`wemail-production`) | 消息按保留期由 cron 清理 | **高**:唯一事实源 |
-| 附件二进制 | R2(`wemail-production-attachments`) | 随消息删除清理 | 中:按业务对附件的依赖决定 |
-| Worker 配置 | `wrangler.toml` + GitHub Environment secrets | 随仓库 | 已随 git 管理 |
+| 用户、邮箱、消息元数据、设置、投递记录、审计 | D1(`DB` 绑定) | 消息按保留期由 cron 清理 | **高**:唯一事实源 |
+| 附件二进制 | R2(`ATTACHMENTS` 绑定) | 随消息删除清理 | 按附件恢复要求决定;D1 元数据无法重建原附件 |
+| Worker 公共配置 | `apps/worker/wrangler.toml` | 随仓库 | 已由 Git 管理,远端 D1/KV ID 保留占位值 |
+| 绑定 ID、部署凭证、运行时密钥 | GitHub Environment secrets、Worker secrets | 仓库外维护 | 单独保存配置清单与安全恢复路径,Git 不包含这些值 |
-注意:D1 导出**不包含** R2 附件。只恢复 D1 时,消息记录完整,但附件下载会 404。
+D1 导出**不包含** R2 附件。只恢复 D1 时,仍需检查恢复记录对应的 R2 对象是否存在;源桶对象已清理时,必须从附件备份还原。
## 2. 备份
-### 2.1 D1 导出(每日 / 每次发布前)
+### 2.1 记录备份上下文
+
+记录环境、Cloudflare 账号、源数据库 ID、备份时间、应用 commit、各业务表行数、索引与 `d1_migrations` 记录。备份文件包含用户和邮件数据,保存到仓库外的受控备份目录。恢复窗口内暂停业务写入、Email Routing 投递和清理任务,避免备份后的新数据在切库时丢失;同时安排入站恢复或重投。
+
+### 2.2 准备私有 Wrangler 配置
+
+GitHub Actions 在运行时用当前 Environment 的 `CLOUDFLARE_D1_DATABASE_ID`、`CLOUDFLARE_KV_NAMESPACE_ID`、`CLOUDFLARE_KV_PREVIEW_NAMESPACE_ID` 替换占位值。本地 Wrangler 不会读取 GitHub secrets,不能直接用公开配置操作远端 `DB`。
+
+在 `apps/worker` 下创建已被 `.gitignore` 忽略的私有副本:
```bash
cd apps/worker
-pnpm exec wrangler d1 export wemail-production --remote --output backup-$(date +%Y%m%d).sql
+mkdir -p .wrangler
+cp wrangler.toml .wrangler/wrangler.recovery.toml
```
-导出文件包含建表语句、全部数据行和索引定义(含入站幂等唯一索引
-`idx_mail_messages_account_message_id`)。建议异地保存(对象存储、加密归档)。
+编辑该副本:将 `main` 改为 `../src/index.ts`,各 `migrations_dir` 改为 `../src/infrastructure/db/migrations`;填入目标环境的真实 D1/KV ID,保留需要的路由、变量与 R2 bucket。配置移入 `.wrangler/` 后,路径相对该目录解析。生产资源 ID 和密钥不写回公开配置,也不打印到日志。运行时密钥仍由 Cloudflare Worker secrets 管理。
-### 2.2 R2 附件清单核对(每周)
+下文私有配置中的 production `DB` 初始指向源库;在切换前保留源库配置副本,以便回退。
+
+### 2.3 D1 导出(每日 / 每次发布前)
```bash
-pnpm exec wrangler r2 object list wemail-production-attachments --remote --prefix attachments/
+pnpm exec wrangler d1 export DB --env production --remote \
+ --config .wrangler/wrangler.recovery.toml --output /secure/wemail-backup/backup-YYYYMMDD.sql
```
-如业务依赖附件完整性,用 S3 兼容 API(`wrangler r2 token create` 生成凭据)做桶级
-同步到备份桶;附件随消息有保留期,属可再生数据,默认可不备份二进制。
+确认文件存在且非空,核对 schema、数据、索引及迁移台账,与备份上下文一起保存。导出后应在隔离的新库验证导入,不能仅以文件生成成功认定恢复可用。[D1 导入导出说明](https://developers.cloudflare.com/d1/best-practices/import-export-data/)
-## 3. 恢复
+### 2.4 R2 附件清单与备份
-**恢复目标是全新数据库**:导出文件含 `CREATE TABLE`,对已有数据的库执行会在建表
-阶段失败(这本身是一道防误覆盖的安全闸)。
+当前仓库的 Wrangler 4.105.0 提供 `r2 object get/put/delete`,没有 `r2 object list` 或 `r2 token create`。通过 Cloudflare Dashboard 创建限制到目标 bucket 的 R2 S3 凭据,再按 [R2 凭据说明](https://developers.cloudflare.com/r2/api/tokens/)和 [AWS CLI 示例](https://developers.cloudflare.com/r2/examples/aws/aws-cli/)配置独立 profile(region 使用 `auto`)。凭据不要作为命令行参数或提交到仓库。
```bash
-# 1. 建新库
-pnpm exec wrangler d1 create wemail-production-restore
+aws configure --profile wemail-r2-backup
+aws s3api list-objects-v2 --profile wemail-r2-backup \
+ --bucket wemail-production-attachments --prefix attachments/ \
+ --endpoint-url "https://.r2.cloudflarestorage.com" \
+ > /secure/wemail-backup/attachment-manifest.json
+aws s3 sync s3://wemail-production-attachments/attachments/ \
+ /secure/wemail-backup/attachments/ --profile wemail-r2-backup \
+ --endpoint-url "https://.r2.cloudflarestorage.com"
+```
+
+核对清单是否完整(分页由 AWS CLI 处理),保存对象数量、key、大小与备份时间。上述命令是 S3 兼容操作示例,当前未对真实桶演练;每次恢复应验证样本附件下载和完整性。省略二进制备份意味着恢复目标不包含已丢失的附件。
-# 2. 导入备份
-pnpm exec wrangler d1 execute wemail-production-restore --remote --file backup-YYYYMMDD.sql
+## 3. 恢复
-# 3. 切换绑定:编辑 wrangler.toml 的 [env.production.d1_databases],
-# database_id 指向新库;R2 不变则无需动
+### 3.1 导入全新数据库
-# 4. 重新部署(迁移步骤对新库是空操作,导出已含全部 schema)
-# GitHub Actions: Deploy Cloudflare → production
-```
+恢复到全新数据库,避免对已有业务数据执行含建表和插入语句的 SQL。建表冲突不构成可靠的防覆盖机制,导入前必须确认目标 ID。
-回退到旧库 = 把 `database_id` 改回原值再部署一次,原库未被修改。
+```bash
+pnpm exec wrangler d1 create wemail-production-restore
+```
-### 3.1 恢复后验证
+把新库的 ID 和名称填入私有配置的 `[[env.production.d1_databases]]`;`DB` 此时指向恢复库。不要把新库 ID 写入公开 `wrangler.toml`。然后执行:
```bash
-# 行数核对(与备份时记录的数字对照)
-pnpm exec wrangler d1 execute wemail-production-restore --remote \
+pnpm exec wrangler d1 execute DB --env production --remote \
+ --config .wrangler/wrangler.recovery.toml --file /secure/wemail-backup/backup-YYYYMMDD.sql
+pnpm exec wrangler d1 execute DB --env production --remote \
+ --config .wrangler/wrangler.recovery.toml \
--command "SELECT (SELECT COUNT(*) FROM users) AS users, (SELECT COUNT(*) FROM accounts) AS mailboxes, (SELECT COUNT(*) FROM mail_messages) AS messages"
-
-# 幂等索引在位
-pnpm exec wrangler d1 execute wemail-production-restore --remote \
+pnpm exec wrangler d1 execute DB --env production --remote \
+ --config .wrangler/wrangler.recovery.toml \
--command "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_mail_messages_account_message_id'"
+pnpm exec wrangler d1 execute DB --env production --remote \
+ --config .wrangler/wrangler.recovery.toml --command "SELECT name FROM d1_migrations ORDER BY id"
+pnpm exec wrangler d1 migrations list DB --env production --remote \
+ --config .wrangler/wrangler.recovery.toml
+```
+
+把结果与备份上下文比较,并确认待部署代码所需迁移。导出 schema 不意味着迁移自动为空操作:若 `d1_migrations` 缺失或与 schema 不一致,先恢复备份中的真实迁移台账并验证,不能重新盲跑所有建表/加列迁移。若备份早于当前代码,只应用已审查的增量迁移;schema 与记录无法核对时先恢复到与备份匹配的应用版本。[D1 迁移命令](https://developers.cloudflare.com/d1/wrangler-commands/#d1-migrations-list)
+
+### 3.2 切换应用绑定
+
+| 部署方式 | 切库步骤 | 回退到旧库 |
+| --- | --- | --- |
+| GitHub Actions(推荐) | 更新 **production GitHub Environment** 的 `CLOUDFLARE_D1_DATABASE_ID` 为恢复库 ID;保留原值的安全记录,再运行 `Deploy Cloudflare → production` | 把同一个 Environment secret 恢复为旧库 ID,并重新部署兼容的应用 commit |
+| 手动部署 | 在私有配置中确认恢复库的 `database_id`、`database_name`,使用下面的部署命令 | 用保留的源库私有配置恢复 ID/名称,再部署兼容的代码 |
+
+Actions 的公开配置名称仍可能显示 `wemail-production`,真正的绑定目标由注入的 `database_id` 决定。工作流先通过 `DB` 应用迁移,再部署 Worker;完成 3.1 的迁移核对后才能触发。只改本地 TOML 不会改变下一次 Actions 的目标。R2 未切桶时无需修改其绑定,但必须核对对象与恢复数据是否匹配。
+
+手动部署在 `apps/worker` 下执行:
+
+```bash
+pnpm exec wrangler d1 migrations apply DB --env production --remote \
+ --config .wrangler/wrangler.recovery.toml
+pnpm exec wrangler deploy --env production --config .wrangler/wrangler.recovery.toml
```
-应用层验证:健康检查 `GET /api/system/health` 返回 ok;登录一个已知账号;投递一封
-测试邮件确认入站链路(幂等索引生效的最好证明:同一封重投只入库一次)。
+旧库未由上述导入步骤修改;应用开始向恢复库写入后,两库会产生差异。回退前核对切库后新增数据与外部投递状态,不能把换回绑定等同于数据无损回滚。
+
+### 3.3 恢复后验证
+
+健康检查 `GET /api/system/health` 返回正常;登录已知账号;创建测试邮箱;投递邮件并确认列表、详情、提取结果与附件下载;重复投递同一 Message-ID 只生成一条消息。验证通知和发件记录,核对管理员与成员访问边界,再恢复清理任务和正常流量。记录环境、commit、源/目标库、时间、命令退出状态、行数/索引对比与业务验收结果。
+
+## 4. 历史本地演练记录(2026-09-05)
+
+以下记录来自当时的手册,演练产物已清理,本次没有重新执行这些数据操作:
+
+- `wrangler d1 export DB --local`:548 行 SQL,267 条 INSERT。
+- 新建 scratch 库执行导入:users=8、messages=3,与源一致。
+- 唯一索引 `idx_mail_messages_account_message_id` 随恢复还原。
+- 源库未修改;未验证远端绑定切换、迁移台账与 R2 附件恢复。
-## 4. 本地演练记录(2026-09-05)
+## 5. 与诊断 API 的关系
-- `wrangler d1 export DB --local`:548 行 SQL,267 条 INSERT
-- 新建 scratch 库执行导入:users=8、messages=3,与源一致
-- 唯一索引 `idx_mail_messages_account_message_id` 随恢复完整还原
-- 演练产物已清理;源库未被修改
+管理员可通过 `GET /api/system/reliability` 的 `backupRunbook` 获取命令摘要;系统设置里的历史「可靠性后台」面板已移除。摘要中的 `` 等占位值需要结合环境和私有配置使用,完整恢复流程以本文件为准。
-## 5. 与系统内诊断的关系
+## 6. 命令核验范围(2026-10-04)
-`GET /api/system/reliability` 的 `backupRunbook` 字段给出本页三条核心命令的摘要,
-供运维在后台快速复制;完整步骤以本文件为准。
+已核对仓库安装的 `wrangler --version`(4.105.0)、`d1 export --help`、`d1 execute --help`、`r2 --help`、`r2 object --help`,以及 Cloudflare 官方 D1/R2 文档。该核验确认命令和配置步骤,不等于真实远端备份、导入、切库或 S3 同步演练。
diff --git a/docs/deploy-runbook.md b/docs/deploy-runbook.md
index 2c235b0..ec94df0 100644
--- a/docs/deploy-runbook.md
+++ b/docs/deploy-runbook.md
@@ -143,7 +143,7 @@ pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET --env production
- `[[env.staging.d1_databases]]` / `[[env.production.d1_databases]]`
- `[[env.staging.kv_namespaces]]` / `[[env.production.kv_namespaces]]`
- `[[env.staging.r2_buckets]]` / `[[env.production.r2_buckets]]`(如启用附件存储)
-- `[env.staging.triggers]` / `[env.production.triggers]` 的 `crons = ["0 * * * *"]`:驱动 `scheduled` 处理器执行 `runCleanup`(过期邮件、软删除邮箱清理)。漏配此项时清理任务不会运行,数据将按保留期无限累积
+- `[env.staging.triggers]` / `[env.production.triggers]` 的 `crons = ["* * * * *", "0 * * * *"]`:每分钟恢复持久化通知任务,每小时执行 `runCleanup`(过期邮件、软删除邮箱清理)。两项均需保留;`pnpm preflight` 会分别检查。通知任务与投递语义见 `docs/adr/0003-durable-notification-outbox.md`。
- AI、Rate Limiter、Queues 等其他启用的绑定
## 4. 上线流程总览
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 2dd7448..cbaf282 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -709,7 +709,8 @@
],
"keyword": "code",
"quietHoursStart": "23:00",
- "quietHoursEnd": "08:00"
+ "quietHoursEnd": "08:00",
+ "quietHoursTimezone": "Asia/Shanghai"
}
}
}
@@ -782,6 +783,160 @@
}
}
},
+ "/api/notification/deliveries": {
+ "get": {
+ "tags": [
+ "Webhook"
+ ],
+ "summary": "获取本人通知任务与状态统计",
+ "operationId": "getNotificationDeliveries",
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "target",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "webhook",
+ "telegram",
+ "slack",
+ "discord",
+ "feishu",
+ "wecom"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "状态统计与最近投递元数据,不含原始邮件、验证码或租约"
+ },
+ "401": {
+ "$ref": "#/components/responses/Error"
+ }
+ }
+ }
+ },
+ "/api/notification/deliveries/{id}/retry": {
+ "post": {
+ "tags": [
+ "Webhook"
+ ],
+ "summary": "重试本人通知任务",
+ "operationId": "retryNotificationDelivery",
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/IdPath"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "更新后的状态"
+ },
+ "409": {
+ "description": "任务不可用、已过期或正在投递"
+ }
+ }
+ }
+ },
+ "/api/notification/rules/test": {
+ "post": {
+ "tags": [
+ "Webhook"
+ ],
+ "summary": "试运行通知规则并解释结果",
+ "operationId": "testNotificationRules",
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "target",
+ "eventType"
+ ],
+ "properties": {
+ "target": {
+ "type": "string"
+ },
+ "targetId": {
+ "type": "string"
+ },
+ "eventType": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object"
+ },
+ "evaluatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "shouldSend、默认策略和逐条规则匹配/抑制原因"
+ },
+ "400": {
+ "$ref": "#/components/responses/Error"
+ }
+ }
+ }
+ },
+ "/api/system/notification-status": {
+ "get": {
+ "tags": [
+ "系统设置"
+ ],
+ "summary": "管理员查看全站通知积压与结果",
+ "operationId": "getSystemNotificationStatus",
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "全站状态统计与最近任务元数据"
+ },
+ "403": {
+ "$ref": "#/components/responses/Error"
+ }
+ }
+ }
+ },
"/api/announcements": {
"get": {
"tags": [
diff --git a/docs/plans/2026-06-28-product-maturity.md b/docs/plans/2026-06-28-product-maturity.md
index 3e1c3d9..0fd7913 100644
--- a/docs/plans/2026-06-28-product-maturity.md
+++ b/docs/plans/2026-06-28-product-maturity.md
@@ -1,10 +1,10 @@
-# Product Maturity Implementation Plan
+# Product Maturity Implementation Plan (Historical)
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+> 历史计划与分批实现记录,编制于 2026-06-28;当前范围于 2026-10-04 按源码重新核对。文中的 `Completed` / `Added` 记录当时的交付,不代表当前界面仍保留,也不代表真实外部联调或生产验收。后续顺序以 [2026-10-03 开发计划](2026-10-03-next-development-roadmap.md)为准。
**Goal:** Implement the full product-maturity target across the eight directions discussed with the user, while shipping the work in verified batches that keep the product usable at every step.
-**Architecture:** Treat maturity work as a staged roadmap with an admin-visible maturity overview as the control surface. Each direction must eventually have backend evidence, operator UI, documentation, and tests. The first implementation added an admin-only system diagnostics contract; the second batch adds a product maturity overview so the eight directions remain visible while deeper features are built.
+**Architecture (historical):** The original batches used an admin-visible maturity overview as a control surface. Current system settings focus on appearance, domains, runtime settings, and feature toggles; the operations center, maturity overview, and reliability panels have since been removed. Their admin-only API contracts remain available. Each roadmap direction still requires backend evidence, an appropriate operator surface, documentation, and verified tests before acceptance.
**Tech Stack:** pnpm monorepo, Cloudflare Workers, Hono, D1-backed settings, React 19, Vite, TypeScript, Vitest, Testing Library.
@@ -60,24 +60,30 @@ The goal is complete only when all of these directions have working product surf
7. **Docs and self-service troubleshooting:** scenario docs for independent domains, Telegram Bot, Email Routing, Resend, OAuth, common failures, plus in-product diagnostic guidance.
8. **Data reliability:** D1 migration management, backup/restore runbook, R2 cleanup observability, idempotent mail processing, duplicate notification prevention, and scheduled cleanup run records.
-## Batch Status
+## Batch Status And Current Verification Boundary
-| Batch | Scope | Status |
-| --- | --- | --- |
-| 1 | Admin system diagnostics for deployment readiness | Implemented |
-| 2 | Admin product maturity overview for all eight directions | Implemented |
-| 3 | Delivery/error operations center with recent failures and replay controls | Implemented |
-| 4 | Security governance: scoped API keys | Implemented |
-| 5 | Security governance: session devices, invite policy, audit view | Implemented |
-| 6 | Mail workflow: advanced filters, retention countdown, batch operations | Implemented |
-| 7 | Notification rules engine and integration expansion hooks | Implemented |
-| 8 | Outbound maturity: identity checks, templates, DNS checks, retry UX | Implemented |
-| 9 | Plans, teams, quotas, reliability logs, and backup/restore docs | Implemented |
+| Batch | Scope | Current implementation | Remaining acceptance boundary |
+| --- | --- | --- | --- |
+| 1 | Admin deployment diagnostics | 已实现:管理员诊断 API 保留;历史系统设置诊断面板已移除 | 绑定和密钥存在性检查不能证明真实服务可用 |
+| 2 | Overview of eight maturity directions | 已实现:成熟度摘要 API 保留;历史总览面板已移除 | 汇总状态或进度百分比不能证明八个方向已完成 |
+| 3 | Delivery/error operations center | 已实现:operations API 保留;运维中心已移除,旧路由重定向到系统设置 | 投递记录与手动重放有实现;通知持久化、自动恢复和积压状态仍需后续闭环 |
+| 4 | Scoped API keys | 已实现:scope 存储、UI 与路由权限校验 | 真实 Worker/D1 业务 E2E 与 staging 权限验收需保存证据 |
+| 5 | Sessions, invites, audit | 已实现:会话、登录历史、邀请码策略与审计 | 路由/组件测试主要使用内存 store 或模拟 API;真实环境验收另记 |
+| 6 | Mail workflow depth | 已实现:高级过滤、保留期、批量操作与详情 | 真实收件、附件、多结果提取与旧数据兼容仍需验收 |
+| 7 | Notification rules and chat channels | 已实现:规则、Webhook、Telegram、Slack、Discord、飞书、企业微信 | 聊天重放与对象型提取值为 DEV-01 修复项;持久化重试与真实平台验收另行推进 |
+| 8 | Outbound maturity | 已实现:额度、模板、发件身份、DNS 检查摘要与重试入口 | 立即循环尚未落实配置的重试间隔;供应商受理与最终投递未区分;真实 DNS/供应商验收另记 |
+| 9 | Commercial summaries and reliability | 已实现:套餐展示、部署用量、`default` 组织汇总、清理记录、入站幂等、reliability API 与备份手册 | 独立团队/成员/共享邮箱授权未实现;可靠性面板已移除;当前版本远端备份恢复未获验收证据 |
+
+状态口径:**已实现**以当前源码和测试入口为依据;**已联调**需要指明 commit、测试环境、真实组件与替身、通过记录;**已上线验收**需要真实部署和外部链路证据。本表只确认实现范围,不授予后两种状态。验证命令和当前测试边界见[测试策略](../testing-strategy.md)。
+
+`GET /api/users/commercial` 将全部署用户、邮箱、额度与近期审计聚合到一个 `default` 组织;用户数大于 1 会被推断为 `team` 展示。这不构成组织成员关系、共享邮箱授权、团队资源隔离或已上线收费套餐。完整商业与团队模型另列后续方案。
---
## Implementation Batch: Delivery/Error Operations Center
+**Current-state note:** The historical 「运维中心」rail is removed. `/system/operations` redirects to `/system/settings`; `GET /api/system/operations` remains admin-only. The following list records its original implementation, not a current navigation entry.
+
### Completed In This Batch
- Added admin-only `GET /api/system/operations` for recent failure aggregation and operational signals.
@@ -137,6 +143,8 @@ The goal is complete only when all of these directions have working product surf
## Implementation Batch: Outbound Maturity
+**Current-state note:** The maturity summary and compose shortcuts remain implemented. Retry policy display does not establish that its delay is executed, and the existing `sent` status means provider acceptance rather than verified delivery. DEV-06 supplies the execution and final-status closure; no live Resend/DNS acceptance evidence is recorded here.
+
### Completed In This Batch
- Added `GET /api/mail/outbound/maturity` returning outbound quota, retry policy, failure stats, Return-Path status, sender identity checks, SPF/DKIM/DMARC checklist, and built-in send templates.
@@ -149,18 +157,22 @@ The goal is complete only when all of these directions have working product surf
## Implementation Batch: Plans, Teams, Quotas, And Reliability
+**Current-state note:** Commercial data is a deployment summary with one `default` organization, not independent teams or shared-mailbox authorization. The current users settings panel is 「商业与团队模型」. The historical 「可靠性后台」system panel is removed while its admin API remains. Message-ID now uses a persistent unique constraint; the five-minute window below describes the earlier fallback behavior. Backup/restore acceptance follows the current [runbook](../backup-restore-runbook.md), not this historical implementation label.
+
### Completed In This Batch
-- Added `GET /api/users/commercial` for plan tiers, default organization workspace, member roles, shared mailbox usage, quota usage, and organization audit summaries.
-- Added a users settings「套餐、团队与配额」panel for commercial model and team workspace visibility.
+- Added `GET /api/users/commercial` for displayed plan tiers, deployment quota usage, one default organization summary, and recent deployment audit events. User roles and mailbox totals are summarized; this did not add organization membership or shared-mailbox grants.
+- Added the historical users settings「套餐、团队与配额」panel for commercial summaries; its current successor is 「商业与团队模型」.
- Added cleanup run persistence with D1 migration `0017-cleanup-runs.sql`, in-memory/D1 store support, and scheduled cleanup success/failure recording.
- Added inbound idempotency for duplicate messages within a 5-minute window and suppressed duplicate Webhook/Telegram notifications.
- Added `GET /api/system/reliability` for D1/R2 status, migration evidence, cleanup runs, idempotency policy, and backup/restore runbook commands.
-- Added a system settings「可靠性后台」panel and tests covering commercial summaries, cleanup records, reliability summaries, and duplicate inbound suppression.
+- Added the historical system settings「可靠性后台」panel (since removed) and tests covering commercial summaries, cleanup records, reliability summaries, and duplicate inbound suppression.
---
-## Implementation Batch: Phase 1 System Diagnostics
+## Historical Implementation Steps: Phase 1 System Diagnostics
+
+The steps and paths below describe the original implementation. They are retained for history; current Worker modules live under `app/`, `core/`, `infrastructure/`, and `shared/`, and removed system panels are not new work items. Use current package scripts from the testing strategy for verification.
### Task 1: Add The Shared Diagnostics Contract And Worker Endpoint
@@ -315,7 +327,7 @@ Do not commit automatically in this session unless the user asks for a commit.
**Step 1: Update documentation**
-- Keep this plan as the source of truth for maturity roadmap sequencing.
+- Retain this plan as historical context; use the current next-development roadmap for sequencing and acceptance gates.
- Add a `CHANGELOG.md` entry under `[Unreleased]` describing the new admin diagnostics surface.
**Step 2: Run focused verification**
@@ -342,6 +354,8 @@ Expected: all commands pass.
## Implementation Batch: Product Maturity Overview
+**Current-state note:** The overview API remains available; the historical UI described in Task 5 is removed. It does not currently serve as a product acceptance dashboard.
+
### Task 4: Add The Shared Maturity Contract And Worker Endpoint
**Files:**
diff --git a/docs/plans/2026-10-03-next-development-roadmap.md b/docs/plans/2026-10-03-next-development-roadmap.md
new file mode 100644
index 0000000..c4dcf43
--- /dev/null
+++ b/docs/plans/2026-10-03-next-development-roadmap.md
@@ -0,0 +1,189 @@
+# WeMail 下一步开发计划
+
+编制日期:2026-10-03
+
+代码基线:`9915e27`,根 `package.json` 版本 `0.4.0`
+
+状态:自 2026-10-04 起按顺序执行;实际状态与证据见 [执行记录](2026-10-04-development-execution.md)。版本号仍为候选,实际切版另行记录。
+
+## 目标与排期假设
+
+下一轮以“邮件到达后,提取可信、通知可恢复、发件状态可解释、自托管可验证”为目标。优先补齐已有能力的业务闭环,继续围绕临时邮箱、验证码/链接提取与自动化集成建立产品优势。
+
+按一名熟悉项目的全栈开发者估算,核心工作约 **22–37 个开发日**;预留联调与环境准备时间,建议安排 **6–9 周**。以下工时是规划估算,阶段一完成后根据实际基线重新估算。第三方账号、测试域名、staging 资源与凭证的准备时间单独计算。
+
+本计划基于本地源码、测试、工作流与文档核对。没有查询线上部署、远端 CI 或真实邮件投递状态;“已有实现”不等于“线上已验收”。
+
+## 当前能力与真实缺口
+
+| 方向 | 当前证据 | 下一步判断 |
+| --- | --- | --- |
+| 收件与提取 | 已有 Message-ID 幂等、多结果提取、中英文验证码、有效期提示、发件认证摘要、AI fallback | 扩展样本评估与失败恢复;不重复开发已有提取能力 |
+| 多渠道通知 | 已有通用 Webhook、Telegram、Slack、Discord、飞书、企业微信,规则与投递记录 | 先修聊天渠道重放和提取内容展示,再补持久化重试 |
+| 发件 | 已有 Resend、额度、发送记录、失败入口、发件身份和 DNS 检查摘要 | 重试间隔与真实执行需一致;供应商接收与最终投递状态需区分 |
+| 安全治理 | 已有会话管理、登录历史、API Key scope、邀请码策略与审计 | 在真实链路中验证权限与额度,按发现补缺口 |
+| 自托管与可靠性 | 已有部署手册、preflight、迁移编号检查、清理记录、备份恢复手册 | 补自动化验收和部署方式一致性,继续验证恢复流程 |
+| 团队与套餐 | 商业摘要按用户数/邮箱数推断套餐,返回一个 `default` 组织 | 当前属于汇总展示;独立团队、成员关系与资源隔离另列为后续阶段 |
+| 浏览器验证 | Playwright smoke 大量使用 `page.route()`;工作流只构建 Web preview | 增加连接真实本地 Worker/D1 的业务测试,保留现有前端 smoke |
+
+历史计划 `2026-06-28-product-maturity.md` 中的 Implemented 状态只能作为历史记录。已移除的运维中心、展示性的团队摘要和真实闭环要分别说明,不能据此宣称八个方向全部完成。
+
+## 阶段一:修复通知缺口,建立验证基线
+
+**优先级:P0;预计 4–6 个开发日;候选版本:0.4.1。**
+
+### DEV-01 聊天渠道重放与内容修复(2–3 日)
+
+源码依据:`apps/worker/src/app/services/webhook-service.ts`。
+
+- 当前 `sendWebhookEventToEndpoint` 将平台渲染后的 `body` 保存为 `payloadJson`,而 `retryWebhookDelivery` 按含 `eventType` 和 `data` 的通用事件解析。聊天渠道请求体不符合该契约,重放可能退化为只有事件标题。
+- 同一服务的 `buildChannelText` 只保留字符串值,但入站 `message.extracted` 的 `extraction` 是对象,验证码/链接不会自动进入聊天文本。
+- 保存稳定的原始事件与独立的平台请求体;重试读取原始事件,再按目标渠道渲染。兼容旧通用 Webhook 记录,对无法还原的旧聊天记录给出明确错误。
+- 将主提取结果、有效期和必要上下文映射为聊天文本;处理长度限制。统一投递超时,并检查平台业务响应,避免仅凭 HTTP 200 判定成功。
+
+验收:五种 Webhook 渠道都覆盖“首次失败 → 手动重试成功”;重试后的主题、发件人和提取值保持完整;原始事件签名契约与旧通用记录继续兼容;无越权重放。
+
+### DEV-02 基线与文档一致性(2–3 日)
+
+- 执行完整 lint/typecheck/test/build 与现有 E2E,记录基线结果、耗时和阻塞项。
+- 整理功能状态为“已实现 / 已联调 / 已上线验收”,同步旧成熟度计划和测试目录说明。
+- 修正根 README 的旧版本徽章;版本仍以根 `package.json` 为准。
+- 核对备份恢复手册:GitHub Actions 的绑定来自 Environment secrets,恢复切库步骤要与此一致;保留手动部署分支说明。
+- 核验首页“5 分钟部署”等可测量表述,未获得操作记录支撑的内容改为准确说明。
+
+验收:有可复现的基线报告;现有失败逐项登记并解决本阶段阻断项;文档不存在已删除功能仍被描述为现有入口的情况。
+
+## 阶段二:打通真实邮件业务测试
+
+**优先级:P0;预计 3–5 个开发日;依赖 DEV-02。**
+
+### DEV-03 本地 Worker/D1 业务 E2E
+
+- 新增独立业务 E2E 配置,启动本地 Worker、隔离 D1 和 Web;统一准备邀请码、用户与邮箱数据,测试后清理。
+- 覆盖“邀请码注册 → 登录 → 创建邮箱 → 入站处理 → 列表/详情 → 多结果提取 → API Key 读取 → 撤销后拒绝访问”。
+- 将生产入站处理函数纳入受控测试驱动;生产 API 不增加公开测试投递入口。外部邮件供应商用可控适配器,明确测试边界。
+- 覆盖跨用户邮箱访问拒绝、scope 拒绝、额度耗尽、重复 Message-ID、HTML 邮件、附件与失败重试。
+- CI 单独运行业务 E2E,失败上传日志与浏览器产物;保留当前模拟 API 的快速页面测试。
+- 为 staging 列出真实 Email Routing、Resend、聊天平台的验收步骤与证据模板。
+
+验收:本地可用一条命令运行核心业务测试,CI 可重复通过;权限、幂等和失败路径有断言;报告明确哪些外部链路仍需 staging 验收。
+
+## 阶段三:通知可恢复与可观察
+
+**优先级:P1;预计 5–8 个开发日;依赖 DEV-01、DEV-03;候选版本:0.5.0。**
+
+### DEV-04 持久化通知事件与投递任务
+
+源码依据:`apps/worker/src/app/runtime.ts` 在邮件保存后逐项发送通知;重复邮件被抑制后直接返回。由此推断,保存成功后通知阶段发生中断时,重投邮件不能保证补齐通知,需要故障注入验证。
+
+- 设计并记录消息落库与通知事件持久化的一致性边界;以 D1 outbox 作为可恢复事件源,评估 Cron 扫描与 Cloudflare Queues 的成本和部署要求后定案。
+- 投递任务增加状态、尝试次数、下次执行时间、最后错误和稳定事件 ID;以消息、事件类型、目标建立唯一任务约束。
+- 支持指数退避、最大尝试次数、可重试错误分类、失败终态和手动重放;HTTP 429 尊重合理的 Retry-After。
+- 将“事件 ID”与“单次尝试 ID”分开,方便接收方去重。外部调用采用至少一次投递语义,不承诺不可实现的端到端 exactly-once。
+- 入站保存成功后,某个通知目标失败不阻塞其他目标;敏感验证码和原始邮件内容沿用保留策略,避免投递记录无限累积。
+
+验收:通知期间模拟中断后可恢复;重复投递不生成重复任务;最终失败可见、可重放;队列/扫描故障不会导致邮件丢失。
+
+### DEV-05 投递状态与规则可解释性
+
+- 在现有 Webhook/Telegram 与系统设置页面展示待投递、重试中、失败终态、最近成功和积压信息。
+- 为通知免打扰规则明确时区,支持跨午夜;兼容既有 UTC 规则并说明迁移方式。
+- 增加规则测试结果:是否匹配、命中了哪些条件、为何被免打扰规则抑制。
+
+验收:用户能定位“为何没收到通知”并采取操作;待处理计数与数据库任务状态一致;权限遵循现有用户/管理员边界。
+
+## 阶段四:发件执行与状态闭环
+
+**优先级:P1;预计 4–7 个开发日;依赖 DEV-03、DEV-04 的任务模型;候选版本:0.6.0 的一部分。**
+
+### DEV-06 重试执行、幂等与最终状态
+
+源码依据:`apps/worker/src/app/use-cases/outbound-use-cases.ts` 的 `sendEmailWithRetries` 当前立即循环;`retryDelay` 出现在摘要,未进入执行逻辑;发件记录状态是 `sent`/`failed`。
+
+- 将重试间隔转换为执行规则,明确首次发送与后台重试的边界;永久错误不重复发送。
+- 增加稳定的发送请求标识,核对供应商幂等接口;覆盖供应商已接收但本地超时的情形,避免重复邮件。
+- 明确额度按尝试还是逻辑请求计费、失败如何处理;保证并发下限额一致,重试不会意外重复扣减。
+- 接入供应商投递事件前,核对官方验签与事件契约;新增 accepted/delivered/bounced/complained 等状态,兼容旧 `sent` 记录。
+- UI 显示状态时间线、错误原因与可重试操作。单独标注静态配置检查和真实 DNS/供应商检查的来源、检查时间及结果。
+
+验收:配置的等待间隔实际生效;同一请求不会因自动重试重复扣额;供应商事件可验签、可去重、可处理乱序;前端准确区分已受理与已送达。
+
+## 阶段五:提升提取质量和首次使用成功率
+
+**优先级:P1/P2;预计 4–7 个开发日;依赖 DEV-03,可按小批次交付。**
+
+### DEV-07 提取质量评估(2–4 日)
+
+- 建立至少 50 封去敏邮件样本,覆盖中英文、多个验证码、多个链接、转发、订阅退订、有效期、钓鱼样式、价格/日期误识别与异常格式。
+- 固定精确率、召回率和主要误识别类型的基线;规则优先、AI fallback 单独评估成本与错误。
+- 改善多结果的选择/复制、缺少有效期和发件认证未知状态的提示。
+- 核对 Authentication-Results 的信任来源,区分可信上游结论与未经验证的邮件头,避免认证徽章误导用户。
+
+验收:新增规则必须通过固定样本回归;价格/日期等重点负样本不误判;质量改进有前后比较数据;界面不将“未知”呈现为“安全”。
+
+### DEV-08 首次使用与自托管检查(2–3 日)
+
+- 串起“创建首个邮箱 → 说明如何发送测试邮件 → 确认收到 → 复制提取结果 → 可选通知”的引导。
+- 复用已有系统诊断,提供域名/Email Routing、密钥、功能开关、清理任务配置的检查说明与修复入口。
+- 补充三条任务导向文档:用 API 取验证码、推送聊天平台、独立域名自托管。
+
+验收:新用户能完成首封邮件流程;未配置能力提供具体下一步;诊断结论区分配置存在与真实服务可用。
+
+## 阶段六:发布验收
+
+**优先级:P0 发布门槛;累计预计 2–4 个开发日,按每次候选发布分摊。**
+
+- 每个开发批次都更新根 CHANGELOG;只有实际切版时才修改根版本并同步 workspace、共享常量与 OpenAPI。
+- 每次候选发布执行:`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`、`pnpm version:check`、`pnpm api-catalog:check`、迁移编号检查、`pnpm preflight` 与相关 E2E。
+- 新迁移必须验证“空库初始化”和“旧版本升级”;涉及事件/任务结构时验证旧投递记录与未完成任务兼容性。
+- staging 验证真实收件、重复投递、附件、提取、通知重试、发件及权限边界,并保存记录。
+- 对计划发布的 schema 变更演练备份/恢复与旧代码兼容性,明确代码回滚和数据恢复的不同路径。
+- 满足部署手册门槛后再安排 production;发布后按手册观察错误、积压、重复发送和清理失败。
+
+验收:本地与 CI 必要检查通过;staging 核心链路有证据;迁移、旧数据兼容和回滚路径明确;新增 P0/P1 问题关闭。
+
+## 建议里程碑
+
+| 顺序 | 交付内容 | 候选版本 | 放行条件 |
+| --- | --- | --- | --- |
+| M0 | 聊天渠道重放/提取内容修复、基线与文档纠偏 | 0.4.1 | 定向回归与完整基础检查通过 |
+| M1 | 真实业务 E2E、持久化通知、重试状态与规则解释 | 0.5.0 | 故障注入与 staging 通知恢复通过 |
+| M2 | 发件状态闭环、提取评估、首次使用引导 | 0.6.0 | 供应商事件/幂等、样本指标与新用户流程通过 |
+
+里程碑按实际可验收成果发布,不绑定固定日历日期。阶段六的发布成本计入总估算。
+
+## 后续候选:团队与商业模型
+
+待核心链路稳定后,单独编写团队方案:组织、成员、角色、共享邮箱授权、团队 API Key、团队额度与组织审计。先落数据与权限边界,再决定套餐和计费。
+
+第一步是停止把“用户数大于 1”视为真实团队套餐依据;当前展示标注为部署级汇总。完整团队方案需要单独的迁移、越权测试、管理员权限定义和估算,本轮不接支付、不承诺团队功能已完成。
+
+## 第一批可直接执行的工作项
+
+1. 为 DEV-01 增加聊天渠道失败重放、对象型提取值与 HTTP 200 业务失败的回归用例。
+2. 修复事件持久化与平台渲染边界,并验证旧通用 Webhook 记录。
+3. 完成 DEV-02 的全套基线与文档纠偏,记录环境准备缺口。
+4. 搭建 DEV-03 的本地 Worker/D1 测试驱动,先跑通注册、建邮箱、收件与提取。
+5. 依据实际结果细化 DEV-04 的 outbox 一致性与调度 ADR,再进入后续开发。
+
+## 本次计划编制验证
+
+- 已通过 `pnpm version:check`:版本元数据同步为 0.4.0。
+- 已通过 `pnpm api-catalog:check`:接口目录无漂移。
+- 已通过迁移编号检查:24 个迁移文件;历史重复 0013 按脚本现有豁免处理,不代表已消除历史重号。
+- 本次只生成计划文档与导航/Changelog 记录,未执行完整测试、构建、远端 CI 或线上验收;完整基线作为 DEV-02 的交付内容。
+
+## 依据入口
+
+- [根版本与命令](../../package.json)
+- [近期变更](../../CHANGELOG.md)
+- [历史成熟度计划](2026-06-28-product-maturity.md)
+- [Webhook 投递与重放](../../apps/worker/src/app/services/webhook-service.ts)
+- [入站与清理处理](../../apps/worker/src/app/runtime.ts)
+- [发件用例](../../apps/worker/src/app/use-cases/outbound-use-cases.ts)
+- [通知规则](../../apps/worker/src/app/services/notification-rule-service.ts)
+- [商业摘要](../../apps/worker/src/app/use-cases/admin-use-cases.ts)
+- [现有浏览器 smoke](../../apps/web/e2e/smoke.spec.ts)
+- [E2E 工作流](../../.github/workflows/e2e.yml)
+- [发布手册](../deploy-runbook.md)
+- [备份恢复手册](../backup-restore-runbook.md)
diff --git a/docs/plans/2026-10-04-development-execution.md b/docs/plans/2026-10-04-development-execution.md
new file mode 100644
index 0000000..38192bc
--- /dev/null
+++ b/docs/plans/2026-10-04-development-execution.md
@@ -0,0 +1,51 @@
+# WeMail 开发计划执行记录
+
+目标:按顺序完成 [开发计划](2026-10-03-next-development-roadmap.md) 的全部实现与验收。开始日期:2026-10-04。代码分支:`codex/development-roadmap`。
+
+## 进度与证据
+
+| 工作项 | 当前状态 | 已获得的证据 / 待完成项 |
+| --- | --- | --- |
+| DEV-01 聊天渠道修复 | 本地验收通过 | 31 个重放/内容/响应/超时回归通过;2 个空库/升级/D1 保存回归通过;完整批次检查通过;迁移 0024 与 schema 同步 |
+| DEV-02 基线与文档 | 本地验收通过 | 完整 lint/typecheck/test/build/E2E 复验通过;overlay 竞态与 API Key 复制计时器泄漏修复;历史状态与部署/恢复文档已纠偏 |
+| DEV-03 真实业务 E2E | 本地验收通过,等待 CI | `pnpm test:e2e:business` 6/6 通过,涵盖浏览器注册/密码登录/建邮箱/收件/提取、Key 撤销、越权/scope/额度、幂等、HTML/R2 附件、通知重放与发件;CI job 已配置,待远端验证 |
+| DEV-04 持久化通知 | 本地验收通过,等待 CI | 事务内任务、原子领取/租约、退避/Retry-After、失败/抑制终态、手动重放、保留期与删除清理已验证;ADR 0003 已记录 |
+| DEV-05 状态与规则解释 | 本地验收通过,等待 CI | Webhook/Telegram 状态、管理员统计、重放、规则试运行、UTC 兼容/IANA 时区/跨午夜与权限验证通过;桌面和 375px 手机验证通过 |
+| DEV-06 发件闭环 | 待执行 | 实际重试、请求幂等、额度语义、供应商事件、状态 UI 与 DNS 来源尚待实现 |
+| DEV-07 提取质量 | 待执行 | 50 封去敏样本、指标、UI 与认证头信任来源尚待验证 |
+| DEV-08 首次使用 | 待执行 | 首封邮件引导、诊断修复入口、三条任务文档尚待实现 |
+| 发布验收 | 待执行 | 全套本地与 CI、迁移/恢复、真实 staging 链路与 production 观察均需独立证据 |
+| 团队与商业模型候选 | 后续候选 | 本轮只纠正部署级摘要含义;完整团队功能不计入当前实现承诺 |
+
+## 阶段一发现与修复
+
+- Webhook 原始事件与实际平台请求体分开保存,签名仍针对实际请求体计算。
+- 旧聊天记录缺少原始事件时拒绝空白重放;旧通用 JSON 记录继续兼容。
+- 聊天文本展示多提取值和有效期,限制 UTF-8 长度;所有渠道 10 秒超时,响应读取受限。
+- 本地 `schema.sql` 缺少既有 Message-ID、channel 列与两个索引,已补齐并加入迁移快照一致性回归。
+- 初始基线见 [验证报告](2026-10-04-validation-baseline.md)。测试中原有邀请码失败按实际根因修复,不放宽原有断言。
+
+## 续跑约定
+
+本聊天长期目标保留。每小时定时续跑任务 `wemail` 已于 2026-10-05 按用户要求删除,不重新创建定时任务。后续依据当前目标的继续指令、当前文件和实际测试证据推进剩余任务。
+
+每轮结束前更新本记录。复用仍在运行的具体工具会话;进程已结束后从下一项继续。不要把“实现存在”“定向测试通过”“线上已验收”混为同一状态。
+
+## 环境核对
+
+- GitHub 仓库 `WeOpen/WeMail` 可访问;本地 Cloudflare OAuth 可用。
+- GitHub staging Environment secrets/variables 的列表目前为空,需要在发布阶段核对/配置真实隔离资源。
+- 本地开发阶段继续使用隔离测试资源,不把尚未验证的线上状态视为完成。
+
+## 当前验证运行
+
+- 定向 DEV-01:`tests/webhook-delivery-recovery.test.ts` 31/31;当前完整 Worker 测试通过。
+- 数据库:`tests/database-migrations.test.ts` 2/2;全部 25 个 migrations 可应用到空 SQLite 库。
+- 2026-10-05 完整批次复验:lint 4.56 秒、typecheck 6.10 秒、test 10.11 秒、build 8.63 秒、E2E 9.00 秒,退出码均为 0;浏览器 13 通过、2 个已有视觉脚手架显式跳过。
+- 最新日志:`/tmp/wemail-m0-final-{lint,typecheck,test,build,e2e}.log`,命令退出码与耗时见 `/tmp/wemail-m0-final-results.json`。版本/API 目录/迁移编号/preflight 检查通过。
+- DEV-03:6 个真实本地业务场景通过(3.6 秒浏览器执行,整个隔离环境约 9.5 秒);收件截图与浏览器 console 检查已验证。供应商由测试入口适配器控制,不能代替真实 Email Routing/Resend/聊天平台验收。
+- 加强迁移快照检查后发现历史默认值、nullable 与 expiry 索引列顺序漂移,已修正;完整四个 workspace 测试已通过复验。
+- 2026-10-06 DEV-04 定向恢复与数据库测试 13/13;Worker 全套在新增恢复用例前 208/208。完整批次与业务 E2E 待本阶段收尾后验证。
+- DEV-04/05 真实业务 E2E 已扩展到 7 项并通过,包含 UI 重放、规则解释、自动到期恢复与手机宽度检查;桌面/手机截图在 `/tmp/wemail-notification-status-{desktop,mobile}.png`,接口目录与 OpenAPI 已同步。
+- 全量检查中修复了预检输出的兼容断言和新增规则标题导致的旧测试歧义;未移除原有行为断言。一次环境中 pnpm 11 的自动安装检查失败,已使用项目指定的 pnpm 10.18.2 重跑,未新增依赖。
+- 2026-10-06 通知批次:四个 workspace 的 lint/typecheck/test/build 通过;shared 30、Worker 220、Web 339 个测试通过,Docs assert 测试通过;原浏览器 smoke 13 通过/2 个既有视觉脚手架跳过;真实业务 E2E 7/7。最后的计数与规则文案调整也通过定向和业务回归。
diff --git a/docs/plans/2026-10-04-validation-baseline.md b/docs/plans/2026-10-04-validation-baseline.md
new file mode 100644
index 0000000..fe78ad9
--- /dev/null
+++ b/docs/plans/2026-10-04-validation-baseline.md
@@ -0,0 +1,79 @@
+# WeMail 验证基线
+
+记录日期:2026-10-04(Asia/Shanghai)
+
+关联计划:[下一步开发计划 DEV-02](2026-10-03-next-development-roadmap.md#dev-02-基线与文档一致性2–3-日)
+
+## 首次完整基线
+
+实际执行时间:2026-10-04 21:00:48–21:01:36(Asia/Shanghai)。命令按下表顺序串行运行;某条命令失败后继续运行下一条命令,以完整登记 lint、类型、测试、构建和现有 E2E 的状态。
+
+执行环境:macOS 27.0.1 / arm64,Node.js `v24.19.0`,pnpm `10.18.2`,Wrangler `4.105.0`。根项目版本 `0.4.0`,执行起点 HEAD 为 `9915e2759fcd42b25473dadcbb83c65a5b2fcf13`。起点工作区已包含 `CHANGELOG.md`、`docs/README.md` 的未提交修改,以及未跟踪的下一步开发计划;本报告不是干净 checkout 的 CI 结果。
+
+| 命令 | 结果 | 退出码 | 墙钟耗时 | 实际覆盖范围与数量 |
+| --- | --- | ---: | ---: | --- |
+| `pnpm lint` | 通过 | 0 | 5.89 秒 | shared、worker、web、docs 四个 workspace |
+| `pnpm typecheck` | 通过 | 0 | 8.34 秒 | shared、worker、web、docs 四个 workspace;包含 docs 的 MDX 和 Next 路由类型生成 |
+| `pnpm test` | 失败 | 1 | 13.61 秒 | shared 30/30,worker 169/169,web 332/333;合计 531 通过、1 失败,88 个文件通过、1 个文件失败;docs 因前一个 workspace 失败未执行 |
+| `pnpm build` | 通过 | 0 | 10.66 秒 | shared TypeScript 产物、Worker dry-run、Web Vite 产物、Docs Next 产物 |
+| `pnpm test:e2e` | 通过,含显式跳过 | 0 | 9.85 秒 | 共 15 项:13 通过、2 跳过;使用现有 Chromium 与本地 Vite preview |
+
+完整基线命令总耗时为 **48.35 秒**。耗时是这一次本地执行的墙钟观察值,不代表 CI 时长或性能门槛。构建仅验证本地产物与 Worker dry-run,未部署远端资源。
+
+## 已发现的阻断项
+
+### BASE-01:邀请码弹窗关闭后,可访问性状态尚未恢复
+
+- 状态:首次基线中失败;修复与复验结果必须另行登记。
+- 失败用例:`apps/web/src/test/users-global-settings-page.test.tsx` → `UsersGlobalSettingsPage > keeps the invite and quota controls wired to callbacks`。
+- 失败断言位置:首次运行时第 412 行,在创建邀请码弹窗消失后立即查找 `停用邀请码 ALPHA-2026` 按钮。
+- 实际报错:`Unable to find an accessible element with the role "button" and name "停用邀请码 ALPHA-2026"`。
+- 失败时 DOM:页面根容器仍有 `aria-hidden="true"` 与 `data-wemail-inert-count="1"`,`body` 仍有 `overflow: hidden`。因此按钮不可通过可访问角色查询取得;日志本身不能证明邀请码数据缺失。
+- 下一步:核对弹窗退出动画与 inert 清理时机,验证弹窗关闭后底层页面恢复交互;修复应保持模态期间的可访问性隔离。
+- 放行条件:该用例及必要的 overlay 回归通过,随后根 `pnpm test` 实际通过全部 workspace。
+
+根 `scripts/run-task.mjs` 对每个 workspace 顺序执行并在失败时立即退出。为补齐这次基线的覆盖空档,2026-10-04 21:01:50 单独执行了 `pnpm test:docs`:退出码 0,墙钟耗时 **0.34 秒**。它运行 `apps/docs/tests/home-layout.test.mjs` 中的 Node assert 检查,未输出框架测试数量;不能将其计入上述 531 个 Vitest 通过用例,也不能据此将首次 `pnpm test` 改记为通过。
+
+## E2E 覆盖与验证边界
+
+现有 `apps/web/playwright.config.ts` 会启动 `http://127.0.0.1:4173` 的 Vite preview。此次构建成功后运行了现有 `smoke.spec.ts` 与 `design-system.spec.ts`;无需安装新的浏览器或依赖。
+
+13 项通过覆盖首页、登录跳转与 return target、认证页切换、认证后的页面壳层、旧路由重定向、邮件/账号/用户设置、管理员看板、公告及公开设计系统结构。认证与业务数据大量使用 `page.route()` 模拟 API,此次结果证明前端对应路径可以渲染与交互,未证明真实 Worker、D1、Email Routing、Resend 或聊天平台联调成功。
+
+2 项跳过为设计系统浅色/深色截图基线,受 `PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1` 显式开关控制。这次未打开开关、未生成或更新截图基线,不能宣称视觉回归已经验收。
+
+E2E 日志存在 `NO_COLOR` 与 `FORCE_COLOR` 同时设置的 Node 警告;命令正常完成,未登记为阻断项。
+
+## 日志与复现
+
+首次运行完整输出保存在当前机器的以下临时文件。`/tmp` 不是持久化仓库证据;后续 CI 运行应上传对应日志与 Playwright 产物。
+
+| 输出 | 本机位置 |
+| --- | --- |
+| 命令时间、退出码与环境 JSON | `/tmp/wemail-baseline-2026-10-04-results.json` |
+| lint | `/tmp/wemail-baseline-2026-10-04-lint.log` |
+| typecheck | `/tmp/wemail-baseline-2026-10-04-typecheck.log` |
+| test | `/tmp/wemail-baseline-2026-10-04-test.log` |
+| build | `/tmp/wemail-baseline-2026-10-04-build.log` |
+| 现有 E2E | `/tmp/wemail-baseline-2026-10-04-test-e2e.log` |
+| 补充 docs 测试 | `/tmp/wemail-baseline-2026-10-04-test-docs.log` |
+
+在仓库根目录、已安装锁文件对应依赖的环境下,按顺序执行:
+
+```bash
+pnpm lint
+pnpm typecheck
+pnpm test
+pnpm build
+pnpm test:e2e
+```
+
+如缺少 Chromium,先执行仓库已有的 `pnpm test:e2e:install`。本次未发生此环境缺口。
+
+## 后续复验记录
+
+2026-10-05 03:11 起完成修复后的整批复验,五条命令退出码均为 0:lint 4.56 秒、typecheck 6.10 秒、test 10.11 秒、build 8.63 秒、现有 E2E 9.00 秒(13 通过、2 个视觉脚手架显式跳过)。同时通过版本、接口目录、25 个迁移编号与部署 preflight 检查。
+
+BASE-01 已修复:overlay 可访问性清理改为 layout effect,使弹窗移除与恢复底层访问发生在同一 commit,原有邀请码断言不变。额外发现 API Key 复制反馈计时器在页面卸载后触发,已改为生命周期内清理并补充两个回归用例。最新命令退出码与耗时记录在 `/tmp/wemail-m0-final-results.json`;仍不替代 CI 或真实外部链路验收。
+
+修复完成后在本节追加命令、时间、退出码与实际覆盖数量,保留首次失败结果。阶段放行还需按开发计划执行版本一致性、API 目录、迁移编号、preflight 与相关新业务 E2E;本报告的基础五条命令不替代这些门槛。
diff --git a/docs/staging-validation.md b/docs/staging-validation.md
new file mode 100644
index 0000000..d47a25a
--- /dev/null
+++ b/docs/staging-validation.md
@@ -0,0 +1,38 @@
+# Staging 邮件业务验收
+
+本地 `pnpm test:e2e:business` 验证真实 Worker/D1/R2 与浏览器协同,外部供应商为受控替身。本清单记录真实 Email Routing、邮件供应商与聊天平台的独立验收。
+
+## 环境准备
+
+- 隔离 staging D1、KV、R2 与 Pages/Worker 域名,应用全部 migrations;真实 ID 通过 staging GitHub Environment secrets 注入。
+- 核对 Cookie/CORS、Email Routing 域名和收件规则、启用功能与密钥;已有诊断只证明配置存在,需下列实际操作证明服务可用。
+- 为本次验收创建专用用户、邮箱、API Key 和通知目标;以专用测试收件地址完成发件验证。
+
+## 验收步骤
+
+| 项目 | 实际操作 | 必须记录的证据 |
+| --- | --- | --- |
+| 认证与权限 | 邀请注册、登录、第二用户访问第一用户邮箱、缺 scope 与撤销 Key 后调用 | commit、角色、HTTP 状态;不记录密钥和 Cookie |
+| 真实收件 | 从外部测试邮箱向 staging 创建地址发送中英文验证码、多个链接与有效期邮件 | Email Routing 事件、Message-ID、入库 ID、到达时间、提取结果 |
+| 幂等 | 使用同一 Message-ID 重投,再以新 Message-ID 发送相同正文 | 第一次/重复/新消息的 ID 与数量 |
+| HTML/附件 | 发 HTML-only 邮件及含附件邮件,读取正文并下载附件 | 可读正文、文件名/大小、下载校验结果 |
+| 通知与恢复 | 为五种 Webhook 渠道及 Telegram 创建专用目标,核对实际收到的内容;使用可控接收端制造失败再重放 | 稳定事件/尝试 ID、平台消息 ID、重试状态、主题/验证码/有效期;平台真实目标不能用模拟响应替代 |
+| 发件与额度 | 发送到专用外部测试邮箱,核对收件,达到测试额度后再次发送 | 供应商 ID、收件时间、失败/限额状态;后续新增投递事件也需核对最终状态 |
+| 清理与恢复 | 验证保留期清理记录、附件同步清理;导出并恢复到全新隔离 D1,核对索引与数据 | cleanup run ID、恢复数据库/备份时间、行数/索引、恢复后登录和收件 |
+
+## 记录模板
+
+```markdown
+- 日期 / 时区:
+- 发布 commit / 版本:
+- Worker / Pages deployment URL:
+- 数据库与环境(不含访问凭证):
+- 验收项目:
+- 实际操作与 Message-ID / provider ID / delivery ID:
+- 预期与实际结果:
+- 日志 / 截图 / CI artifact 链接:
+- 遗留问题与严重程度:
+- 是否放行:
+```
+
+关闭或撤销专用验收凭证,按保留期清理测试数据。全部必要项目通过前不把本地测试记为“线上已验收”。production 放行与观察仍遵循 `deploy-runbook.md`。
diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md
index 0074fe9..b07bd12 100644
--- a/docs/testing-strategy.md
+++ b/docs/testing-strategy.md
@@ -7,9 +7,20 @@
## 1. 测试层级
- `packages/shared`:纯函数、共享规则、无副作用工具的单元测试
-- `apps/worker`:路由、权限、store、runtime、外部集成边界的集成测试
-- `apps/web`:页面与关键交互的组件 / 集成测试
-- `Playwright e2e`:关键路径的浏览器级 smoke 回归
+- `apps/worker/tests`:纯函数、store、runtime、邮件解析与服务边界测试;`tests/integration` 通过 Hono `app.request()` 验证真实路由、权限与用例协同,主要使用内存 store 和模拟外部服务
+- `apps/web/src/test`:组件测试;`src/test/integration` 验证路由、页面与业务交互,主要使用模拟 `fetch` 响应
+- `apps/web/e2e`:在构建后的 Web preview 中执行 Playwright 页面与交互 smoke;使用 `page.route()` 模拟业务 API
+- `apps/web/e2e-business`:独立配置连接真实本地 Worker、D1 与 R2,外部供应商为受控适配器;隔离数据与进程由 `pnpm test:e2e:business` 管理
+
+## 1.1 功能状态与证据
+
+| 状态 | 最低证据 | 不应据此推断 |
+| --- | --- | --- |
+| 已实现 | 当前源码中的入口、逻辑与相关测试 | 功能已经真实外部联调或上线 |
+| 已联调 | 指定 commit、环境、测试命令和通过记录;明确真实组件与替身的范围 | 模拟 Resend / Telegram / Email Routing 已证明真实服务可用 |
+| 已上线验收 | 指定部署 commit、真实环境、外部链路和验收记录 | 一次历史演练或配置存在能证明后续发布均可用 |
+
+运行时诊断主要检查绑定、配置和密钥存在性;真实网络、域名路由、投递或恢复能力需要单独验证。完整 lint/typecheck/test/build 与 E2E 的运行结果、耗时、环境和阻塞项应保存为本次批次报告,不从目录名称或历史 `Implemented` 状态推断通过。
## 2. 最低覆盖原则
@@ -31,18 +42,18 @@
## 4. 当前 E2E 范围
-当前仓库以 smoke 骨架为主:
+当前浏览器测试已覆盖:
-- landing / auth 可见性
-- settings page
-- admin dashboard smoke
+- landing 与公开设计系统页面结构
+- 未登录深链接跳转、登录/注册切换与登录后的返回目标
+- 成员工作台、邮件/账号设置直达路由和旧路由重定向
+- 管理员用户页、管理员 dashboard、公告查看与管理员发布入口
-以下完整业务流属于下一步扩展目标,需要配合本地或测试环境数据准备:
+这些 smoke 证明前端在给定 API 响应下的行为;登录成功响应、邮箱与管理员数据由测试替身提供。`playwright.config.ts` 与快速 smoke job 只启动 Vite preview,该结果不能证明真实注册、D1 持久化或收件/发件链路。
-- invite 注册
-- mailbox 创建
-- inbox 读取
-- outbound 发送
+连接真实本地 Worker / 隔离 D1 的业务 E2E 使用 `playwright.business.config.ts`:邀请码注册、建邮箱、调用生产入站处理、列表/详情/提取、API Key 生命周期、权限/额度/幂等、HTML/附件与失败重放。runner 仅绑定 127.0.0.1,测试驱动入口只存在于独立测试 bundle,并使用随机 token;生产 API 不增加入站投递入口。Web 构建与数据位于临时目录,退出后清理。
+
+staging 还需使用真实 Email Routing、Resend 与通知平台验证外部链路,并按 [验收清单](staging-validation.md)记录 commit、域名、消息或事件 ID、结果和时间。外部验收不能被本地供应商适配器或模拟 API 的浏览器 smoke 替代。
## 5. 验证命令
@@ -53,23 +64,37 @@ pnpm test
pnpm typecheck
pnpm lint
pnpm build
+pnpm version:check
+pnpm api-catalog:check
+node scripts/check-migration-numbering.mjs
```
以下情况按需补充:
- 改浏览器交互或关键用户流:`pnpm test:e2e`
+- 改真实业务路由、存储或收件/发件链路:`pnpm test:e2e:business`
- 只改 Worker:`pnpm test:worker` / `pnpm test:worker:integration`
- 只改 Web:`pnpm test:web` / `pnpm test:web:integration`
- 只改 shared:`pnpm test:shared`
+- 改迁移或恢复流程:验证空库初始化、旧版本升级、旧记录兼容与恢复演练
+- 候选发布:`pnpm preflight`,相关浏览器回归和 staging 外部链路验收
+
+浏览器 smoke 使用已构建的 Web 产物。本地首次运行:
+
+```bash
+pnpm test:e2e:install
+pnpm --dir apps/web exec vite build
+pnpm test:e2e
+```
## 6. CI / Preview / Release 分工
| Workflow | 触发方式 | 目标 | 说明 |
| --- | --- | --- | --- |
-| `.github/workflows/ci.yml` | `push main` / `pull_request` | 基础质量门禁 | 执行 `pnpm test`、`pnpm typecheck`、`pnpm lint`、`pnpm build` |
-| `.github/workflows/e2e.yml` | `pull_request` / `workflow_dispatch` | 浏览器级回归 | 当前以 smoke 骨架为主,失败时上传 Playwright artifacts |
+| `.github/workflows/ci.yml` | `push main` / `pull_request` | 基础质量门禁 | 版本、迁移编号、接口目录检查与 `pnpm test/typecheck/lint/build` |
+| `.github/workflows/e2e.yml` | `pull_request` / `workflow_dispatch` | 浏览器级回归 | 独立运行模拟 API smoke 与真实本地业务两个 job,上传报告、trace、截图与 Worker 日志 |
| `.github/workflows/deploy-preview.yml` | `pull_request` | PR 预览构建 | 总是上传前端产物;配置好 Cloudflare 后可额外发 Pages preview |
-| `.github/workflows/release.yml` | `workflow_dispatch` / `tag v*` | 发布前总验证 | 面向 release cut,不直接替代生产部署 |
+| `.github/workflows/release.yml` | `workflow_dispatch` / `tag v*` | 发布前总验证 | 检查版本与 tag,执行基础验证;tag 成功时发布 GitHub release,不执行真实外部链路验收或 Cloudflare 生产部署 |
| `.github/workflows/release-drafter.yml` | `push main` | 维护 release draft | 依赖 PR label 自动聚合变更 |
## 7. 文档联动要求
diff --git a/hero-new-1440.png b/hero-new-1440.png
new file mode 100644
index 0000000..3a7f8b7
Binary files /dev/null and b/hero-new-1440.png differ
diff --git a/landing-full-1440.png b/landing-full-1440.png
new file mode 100644
index 0000000..eb9a5cc
Binary files /dev/null and b/landing-full-1440.png differ
diff --git a/landing-hero-1440.png b/landing-hero-1440.png
new file mode 100644
index 0000000..1417907
Binary files /dev/null and b/landing-hero-1440.png differ
diff --git a/landing-mid-1440.png b/landing-mid-1440.png
new file mode 100644
index 0000000..da41b4e
Binary files /dev/null and b/landing-mid-1440.png differ
diff --git a/package.json b/package.json
index 966afa5..125c5aa 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"test:web:integration": "pnpm --dir apps/web exec vitest run src/test/integration",
"openapi:generate": "node apps/worker/scripts/generate-openapi.mjs",
"test:e2e": "pnpm exec playwright test -c apps/web/playwright.config.ts",
+ "test:e2e:business": "node scripts/run-business-e2e.mjs",
"test:e2e:install": "pnpm exec playwright install chromium",
"preflight": "node scripts/preflight-check.mjs"
},
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 960dbb5..26e1313 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -550,6 +550,7 @@ export type NotificationRuleSummary = {
keyword: string;
quietHoursStart: string;
quietHoursEnd: string;
+ quietHoursTimezone?: string;
createdAt: string;
updatedAt: string;
};
@@ -564,6 +565,38 @@ export type NotificationRuleInput = {
keyword?: string;
quietHoursStart?: string;
quietHoursEnd?: string;
+ quietHoursTimezone?: string;
+};
+
+export type NotificationDeliveryStatus = "pending" | "processing" | "retrying" | "succeeded" | "failed" | "suppressed";
+
+export type NotificationDeliverySummary = {
+ id: string;
+ eventId: string;
+ eventType: string;
+ target: NotificationRuleTarget;
+ targetId: string;
+ targetName: string;
+ status: NotificationDeliveryStatus;
+ attempts: number;
+ nextAttemptAt: string;
+ errorText: string | null;
+ createdAt: string;
+ updatedAt: string;
+ expiresAt: string;
+};
+
+export type NotificationStatusSummary = {
+ counts: Record;
+ backlogCount: number;
+ lastSucceededAt: string | null;
+};
+
+export type NotificationRuleEvaluation = {
+ shouldSend: boolean;
+ reason: "matched" | "no_enabled_rules" | "no_matching_rules";
+ evaluatedAt: string;
+ rules: Array<{ id: string; name: string; matched: boolean; reasons: string[]; quietHoursTimezone: string }>;
};
export type MailSettingsSenderRules = {
diff --git a/scripts/README.md b/scripts/README.md
index b77f340..8df5c8d 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -6,6 +6,7 @@
- lint / test / build 编排脚本
- 开发辅助脚本
- 版本同步与发布前检查脚本
+- `run-business-e2e.mjs`:生命周期内启动隔离 Worker/D1/R2 与 Web preview,运行真实本地业务 Playwright 回归,退出时清理临时资源
## 🚫 不放什么
- 只服务某个业务模块的实现代码
diff --git a/scripts/preflight-check.mjs b/scripts/preflight-check.mjs
index 9298fab..dd50249 100644
--- a/scripts/preflight-check.mjs
+++ b/scripts/preflight-check.mjs
@@ -78,7 +78,7 @@ function isPlaceholder(value) {
return typeof value === "string" && value.startsWith("replace-with");
}
-function checkEnvironment(name, env, manualMode) {
+export function checkEnvironment(name, env, manualMode) {
const errors = [];
const warnings = [];
const ok = (label) => console.log(` ✓ ${label}`);
@@ -105,7 +105,10 @@ function checkEnvironment(name, env, manualMode) {
if (!Array.isArray(crons) || crons.length === 0) {
errors.push("缺少 [triggers] crons —— 清理任务不会被调度(0.2.10 的教训)");
} else {
- ok(`清理 cron 已调度(${crons.join(", ")})`);
+ if (!crons.includes("0 * * * *")) errors.push("缺少每小时邮件清理 cron(0 * * * *)");
+ else ok("清理 cron 已调度(0 * * * *)");
+ if (!crons.includes("* * * * *")) errors.push("缺少每分钟通知恢复 cron(* * * * *)");
+ else ok("通知恢复 cron 已配置(* * * * *)");
}
const cors = env.vars?.CORS_ALLOWED_ORIGINS;
@@ -146,7 +149,7 @@ function main() {
console.log(
failures === 0
- ? "自检通过:远端环境配置完整。别忘了 GitHub Environment secrets(CLOUDFLARE_* / VITE_API_BASE_URL)与 Email Routing。"
+ ? "配置文件自检通过。实际资源、GitHub Environment secrets(CLOUDFLARE_* / VITE_API_BASE_URL)与 Email Routing 仍需部署验收。"
: `自检发现 ${failures} 处配置错误(占位符在内),手动部署前必须修复;通过 GitHub Actions 部署则由工作流注入校验。`
);
process.exit(failures === 0 ? 0 : 1);
diff --git a/scripts/run-business-e2e.mjs b/scripts/run-business-e2e.mjs
new file mode 100644
index 0000000..52c2d14
--- /dev/null
+++ b/scripts/run-business-e2e.mjs
@@ -0,0 +1,94 @@
+import { randomBytes } from "node:crypto";
+import { spawn } from "node:child_process";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const temporary = await mkdtemp(join(tmpdir(), "wemail-business-"));
+const configPath = join(temporary, "wrangler.jsonc");
+const statePath = join(temporary, "state");
+const webOutput = join(temporary, "web");
+const backendUrl = "http://127.0.0.1:8789";
+const webUrl = "http://127.0.0.1:4187";
+const token = randomBytes(32).toString("hex");
+const children = new Map();
+const baseEnvironment = { ...process.env, CI: "true", WRANGLER_SEND_METRICS: "false" };
+const logs = { worker: "", web: "" };
+
+function start(args, cwd = root, env = baseEnvironment, logName) {
+ const child = spawn("pnpm", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32" });
+ const complete = new Promise((resolvePromise, reject) => {
+ child.once("error", reject);
+ child.once("exit", (code, signal) => resolvePromise({ code, signal }));
+ });
+ children.set(child, complete);
+ for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => {
+ if (logName) logs[logName] += chunk.toString();
+ else process.stdout.write(chunk);
+ });
+ return { child, complete };
+}
+
+async function run(args, cwd, env) {
+ const { complete } = start(args, cwd, env);
+ const { code } = await complete;
+ if (code !== 0) throw new Error(`Command failed (${code}): pnpm ${args.join(" ")}`);
+}
+
+async function waitForUrl(url, child) {
+ const deadline = Date.now() + 45_000;
+ while (Date.now() < deadline) {
+ if (child.exitCode !== null) throw new Error(`Local server exited (${child.exitCode}): ${url}`);
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
+ if (response.ok) return;
+ } catch { /* The server is still binding its port. */ }
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
+ }
+ throw new Error(`Local server did not become ready: ${url}`);
+}
+
+async function stopChildren() {
+ for (const child of children.keys()) {
+ if (child.exitCode !== null || !child.pid) continue;
+ try {
+ if (process.platform === "win32") child.kill("SIGTERM");
+ else process.kill(-child.pid, "SIGTERM");
+ } catch { /* The child already exited. */ }
+ }
+ await Promise.allSettled([...children.values()]);
+}
+process.once("SIGINT", () => { void stopChildren(); });
+process.once("SIGTERM", () => { void stopChildren(); });
+
+try {
+ await writeFile(configPath, JSON.stringify({
+ name: "wemail-business-test", main: join(root, "apps/worker/tests/business/test-entry.ts"),
+ compatibility_date: "2025-04-01", compatibility_flags: ["nodejs_compat"],
+ vars: { ENVIRONMENT: "local", APP_NAME: "WeMail", COOKIE_NAME: "wemail_session", COOKIE_SECURE: "false", CORS_ALLOWED_ORIGINS: webUrl, RESEND_API_KEY: "business-test-provider-key", ENABLE_AI: "false" },
+ d1_databases: [{ binding: "DB", database_name: "wemail-business", database_id: "business-local", migrations_dir: join(root, "apps/worker/src/infrastructure/db/migrations") }],
+ kv_namespaces: [{ binding: "CACHE", id: "business-local-cache" }],
+ r2_buckets: [{ binding: "ATTACHMENTS", bucket_name: "business-local-attachments" }]
+ }, null, 2));
+ await writeFile(join(temporary, ".dev.vars"), `BUSINESS_TEST_TOKEN=${token}\n`);
+ await run(["exec", "wrangler", "d1", "migrations", "apply", "DB", "--local", "--config", configPath, "--persist-to", statePath]);
+ const worker = start(["exec", "wrangler", "dev", "--local", "--config", configPath, "--persist-to", statePath, "--ip", "127.0.0.1", "--port", "8789", "--inspector-port", "9231"], root, baseEnvironment, "worker");
+ await waitForUrl(`${backendUrl}/api/system/health`, worker.child);
+ const seed = await fetch(`${backendUrl}/__test/seed`, { method: "POST", headers: { authorization: `Bearer ${token}` } });
+ if (!seed.ok) throw new Error(`Test seeding failed: ${await seed.text()}`);
+ await run(["exec", "vite", "build", "--outDir", webOutput], join(root, "apps/web"), { ...baseEnvironment, VITE_API_BASE_URL: backendUrl });
+ const web = start(["exec", "vite", "preview", "--outDir", webOutput, "--host", "127.0.0.1", "--port", "4187", "--strictPort"], join(root, "apps/web"), baseEnvironment, "web");
+ await waitForUrl(webUrl, web.child);
+ await run(["exec", "playwright", "test", "-c", "apps/web/playwright.business.config.ts", ...process.argv.slice(2)], root, { ...baseEnvironment, BUSINESS_API_URL: backendUrl, BUSINESS_WEB_URL: webUrl, BUSINESS_TEST_TOKEN: token });
+} catch (error) {
+ process.exitCode = 1;
+ console.error(error instanceof Error ? error.message : error);
+} finally {
+ await stopChildren();
+ const artifactDirectory = join(root, "apps/web/test-results/business");
+ await mkdir(artifactDirectory, { recursive: true });
+ for (const [name, content] of Object.entries(logs)) await writeFile(join(artifactDirectory, `${name}.log`), content.replaceAll(token, "[test-token]"));
+ await rm(temporary, { recursive: true, force: true });
+}