From c66f608c072c42fc0667b04064f68c0b7e355ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 15:24:22 +0800 Subject: [PATCH 01/41] docs(automation): add email trigger completion design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计邮件触发功能的完善与平台集成方案:向导内联邮箱配置、 系统邮箱 IMAP 设置、服务端校验修复与健壮性改进。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md new file mode 100644 index 0000000..5f96f9a --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -0,0 +1,194 @@ +# 邮件触发功能完善与平台集成 设计方案 + +日期:2026-09-11 +状态:待评审 + +## 一、目标 + +邮件触发的执行链路(服务端调度器每 10 秒轮询邮箱 → 规则匹配 → 优先级竞争 → 去重 → 调用数字员工 → 策略分叉)**已经跑通**,但存在两类问题: + +1. **能力已在后端、用户够不着**:自定义监听邮箱的后端(加密存储、真实 IMAP 连接验证、依赖检查删除)已完整实现,但前端完全没有入口,自动化向导写死"系统邮箱";系统邮箱的 IMAP 配置在平台内完全不可见。 +2. **已知缺陷**:服务端零校验、前端冲突检测未按邮箱隔离、连接失败无告警、匹配逻辑双份维护、存在死代码。 + +本方案让用户在**创建自动化时直接配置监听邮箱**,把系统邮箱纳入平台系统设置,并修复上述缺陷。 + +## 二、范围 + +**纳入**: +- 向导 step 2 内联配置监听邮箱(主路径) +- 自动化页轻量邮箱管理入口(改密码 / 删除 / 看状态) +- 系统设置新增 IMAP 收信配置(需 ragent-service 配合) +- 服务端校验、冲突检测隔离、连接失败告警、规则逻辑去重、死代码清理 + +**不纳入(YAGNI)**: +- 附件内容解析后传给 AI(现状只传附件名) +- 部门/角色级邮箱共享(已确认:仅创建者本人) +- 调度器多实例分布式锁(DB 唯一约束已防重复触发) +- 自动化页存量 `tt()` 文案迁移到 next-intl + +## 三、现状事实(代码依据,均已在本次设计中核实) + +| 事实 | 位置 | +|---|---| +| 调度器双路径已支持自定义邮箱,**零改动可用** | `automation-scheduler.ts:319-339` | +| 分组与去重键为 `userId:mailboxKey` | `automation-scheduler.ts:555` | +| 邮箱表 upsert 为**全字段覆盖** | `mailboxes.ts:145-155` | +| 加密密钥回退到 `JWT_SECRET` | `mailboxes.ts:51` | +| 删除邮箱**未清理游标** | `mailboxes.ts:195-218` | +| 通知是**从 runs/actions 表实时派生**,无通知表可插入 | `store.ts:2163-2320` | +| `email_failed` kind 仅来自失败的 email 类 run action | `store.ts:2282` | +| `automationRowToApi` 已返回 `mailboxKey`/`mailboxLabel` | `store.ts:2469-2470` | +| 创建/更新自动化时 `mailboxKey`/`mailboxLabel` **无校验** | `store.ts:725-727`、`875-877` | +| 创建邮箱接口保存前**真实连接 IMAP 验证**,失败即 400 | `pages/api/v1/automation-mailboxes/index.ts:52-63` | +| 前端冲突检测与规则测试**未按邮箱隔离** | `page.tsx:663-681`、`683-754` | +| `triggerDetail` 硬编码"系统邮箱" | `page.tsx:1292-1299`、`1101-1105` | +| 系统设置 SMTP 密码**明文回传前端** | `system-settings/page.tsx:195` | +| 测试基建:`node --experimental-strip-types --test test/*.test.ts`,测试直接 import lib 纯函数 | `package.json:11`、`test/chatSse.test.ts` | + +## 四、模块设计 + +### 模块 A:系统邮箱 IMAP 配置(系统设置页) + +系统邮箱是平台级共享邮箱,由超管维护,沿用现有 SMTP 配置的形态与权限模型。 + +**ragent-service 侧(外部依赖,需配合)**: +1. system settings 对象新增 `imap_config` 字段(`IMAP_HOST` / `IMAP_PORT` / `IMAP_USERNAME` / `IMAP_PASSWORD` / `IMAP_USE_SSL` / `IMAP_FOLDER`),与现有 `smtp_config` 对称。 +2. `/api/v1/email/unread` 改为读取该配置。 +3. 新增 IMAP 连接测试端点。 + +**本仓库**: +- `app/system-settings/page.tsx`:在现有 SMTP 卡片旁新增"系统邮箱(IMAP 收信)" `CollapsibleCard`,含字段表单 + 保存 + "测试连接"按钮。保存复用现有 `PUT /api/system` 代理透传 `imap_config`。 +- `pages/api/system/index.ts`:透传 `imap_config`(与 `smtp_config` 同样处理)。 +- 新增 `pages/api/system/test-imap.ts`(转发 ragent-service 测试端点,需 super admin 校验)。 + +**安全要求(不照抄 SMTP 的既有缺陷)**: +- 密码字段**只写**:接口只返回 `hasPassword` 布尔值,不回传密码明文。 +- 表单密码留空 = 不修改原值。 + +**权限**:沿用页面现有 `checkSuperAdmin`。 + +### 模块 B:向导内联邮箱配置(主路径) + +`app/automation/page.tsx` step 2 的邮件触发区块,把当前写死的"系统邮箱(固定监听)"卡片改为可选可配: + +``` +监听邮箱 [ 系统邮箱(平台) ▾ ] + ├ 系统邮箱(平台) + ├ 销售部邮箱 sales@corp.com ← 已保存的自定义邮箱(本用户) + └ + 配置新邮箱… ← 选中后展开内联表单 + IMAP 服务器 / 端口 / 账号 / 密码 / 文件夹 + [ 测试连接 ] + ⚠ 该邮箱已被 N 个自动化使用,修改凭据会影响它们 +``` + +- 选中"+ 配置新邮箱…"展开内联表单;测试通过后调 `POST /api/v1/automation-mailboxes` 保存,从响应的 `key` 字段取得 `mailbox:` 写入本次自动化的 `mailboxKey`。 +- 邮箱来源:`GET /api/v1/automation-mailboxes`(已存在)。 +- `buildAutomationPayload()` 的 `mailboxKey`/`mailboxLabel` 从选择派生,移除写死逻辑。 +- `triggerDetail()`(`page.tsx:1292`)与 `localizedTriggerDetail`(`page.tsx:1101`)中的硬编码"系统邮箱"改为使用选中的邮箱标签。 +- `resetWizard()` / `openEditDialog()` / `useTemplate()` 同步处理邮箱字段。 + +**必须处理的三个陷阱**: +1. **密码不得被空值覆盖**:编辑已有邮箱时密码留空表示不修改,需在邮箱更新逻辑中区分"未提供"与"提供空串"。 +2. **不得每次保存自动化都写邮箱**:仅在用户实际改动邮箱配置时才发起邮箱写入请求,否则会重置共享凭据并额外触发一次 IMAP 连接。 +3. **邮箱不可达时无法保存**:POST 接口保存前会真实连接 IMAP,失败返回 400。内联场景下需给出明确提示(当前设计接受此约束,安全优先)。 + +### 模块 C:轻量邮箱管理入口(次级) + +职责仅限"改密码 / 删除 / 看连接状态",与向导内联配置不重复。 + +- 自动化页头部新增次级入口按钮("邮箱管理")→ 抽屉组件 `app/automation/components/MailboxManager.tsx`,列表展示:名称 / 邮箱 / IMAP 服务器 / 状态徽标(`connected` / `error`)/ 最后错误。 +- **编辑(新增后端能力)**:`lib/automation/mailboxes.ts` 新增 `updateAutomationMailbox`(更新前同样真实连接验证一次);`pages/api/v1/automation-mailboxes/[id].ts` 新增 `PUT` 分支。 +- **删除**:复用现有 `DELETE`(已有 409 + dependents 依赖检查)。 +- **编辑邮箱时必须重置游标**(见模块 D)。 +- 新组件使用 next-intl `useTranslations` + `messages/` 文案,遵守 AGENTS.md 规范;不改动自动化页存量 `tt()` 文案。 + +### 模块 D:服务端校验与一致性修复 + +1. **mailboxKey 归属校验**:`store.ts` 的 `createAutomation`(725-727)与 `updateAutomation`(875-877)邮件分支中,若 `mailboxKey` 形如 `mailbox:`,调用 `getAutomationMailboxForUser(userId, id)` 校验归属,不存在则抛错。 +2. **mailboxLabel 服务端派生**:忽略客户端传入的 `mailboxLabel`,一律由邮箱记录派生(避免伪造显示)。`mailboxKey === "system"` 时固定为"系统邮箱",无对应邮箱表记录。 +3. **游标重置**:邮箱的 IMAP 主机 / 账号 / 文件夹变更后,UID 基线完全不同,必须将该 `mailboxKey` 的游标置为 `initialized=false`,否则会漏邮件或重复处理。 +4. **删除邮箱清理游标**:`deleteAutomationMailbox` 中一并删除 `automation_email_mailbox_cursors` 对应行,避免 id 复用导致游标串号。 +5. **规则逻辑去重**:抽取 `lib/automation/mail-rules.ts` 纯函数模块,承载 `doesMailRuleMatch` / `mailRulesSummary` / `mailRuleText` / 字段取值提取,以及规范化类型定义。调度器(`automation-scheduler.ts:242-297`)与前端测试器(`page.tsx:322-371`)均改为 import 该模块,消除双份维护。 +6. **前端冲突检测按邮箱隔离**:`mailConflictCandidates`(`page.tsx:663-681`)与 `mailRuleTestResult`(`page.tsx:683-754`)的过滤条件增加 `mailboxKey` 相等判断,并随邮箱选择联动重算。**修复原因**:调度器只在同一 `userId:mailboxKey` 分组内做优先级竞争,前端跨邮箱比较会误报冲突并预测错误的 winner。 +7. **死代码清理**:删除 `pages/api/automation/check-email.ts`、`pages/api/automation/send-email.ts`(全仓库含文档均无引用)、`page.tsx:462` 的 `LEGACY_DEMO_AUTOMATION_NAMES`。 + +### 模块 E:健壮性改进 + +1. **连接失败告警**:现有通知机制是从 `automation_runs` / `automation_run_actions` **实时派生**的,没有通知表可插入,且邮箱连接失败时没有 run 记录。因此需新增一个健康状态数据源: + - 新表 `automation_mailbox_health(user_id, mailbox_key, last_error, last_error_at)`。 + - `listAutomationNotifications` 的派生逻辑新增第三段查询,eventKey 为 `mailbox::error`,kind 复用已有的 `email_failed`。 + - 带去抖:同一邮箱 10 分钟内最多产生一条提醒。 +2. **邮箱状态标记**:连接失败时更新状态为 `error` 并记录最后错误;成功后恢复 `connected`。模块 C 的列表展示该状态。 +3. **加密密钥前置条件**:`AUTOMATION_MAILBOX_SECRET` 当前不在 `env.example` 中,代码回退到 `JWT_SECRET`(`mailboxes.ts:51`)。**风险**:未显式配置时,轮换 `JWT_SECRET` 会导致所有已存邮箱密码永久无法解密(AES-GCM 认证失败)。处理:写入 `env.example` 并标注为部署前置条件;解密失败时返回明确错误码而非 500。 +4. **去重表保留期**:`automation_email_processed_messages` 无界增长,增加定时清理(保留 30 天,随调度器每日执行一次)。 + +## 五、数据模型变更 + +| 表 | 变更 | 用途 | +|---|---|---| +| `automation_mailbox_health` | 新建 | 邮箱连接健康状态与最后错误(模块 E.1) | +| `automation_mailboxes` | 新增 `last_error` / `last_error_at` 列 | 邮箱状态展示(模块 E.2) | +| `automation_email_mailbox_cursors` | 无结构变更 | 删除/编辑邮箱时清理或重置(模块 D.3、D.4) | + +## 六、API 变更 + +| 端点 | 变更 | +|---|---| +| `GET/POST /api/v1/automation-mailboxes` | 已存在,无变更 | +| `PUT /api/v1/automation-mailboxes/[id]` | **新增**(编辑,含真实连接验证、游标重置) | +| `DELETE /api/v1/automation-mailboxes/[id]` | 已存在;增加游标清理 | +| `PUT /api/system` | 透传 `imap_config` | +| IMAP 测试代理端点 | **新增**(super admin 校验) | +| `POST/PUT /api/v1/automations` | 增加 mailboxKey 归属校验、label 服务端派生 | + +## 七、测试与验收 + +**单元测试**(`pnpm test`,沿用 `node --experimental-strip-types --test`,纯函数可直接 import): +- `lib/automation/mail-rules.ts`:各字段 × 各操作符组合、AND/OR 模式、边界值(空值、大小写、附件扩展名提取) +- `lib/automation/mailboxes.ts`:加解密往返、密码留空保留原值的更新语义 + +**手工验收**: +1. 向导内联配置邮箱(正确凭据 / 错误密码两条路径)→ 保存 → 自动化创建成功 +2. 向该邮箱发测试邮件 → 触发运行 → 运行详情显示邮件来源与命中规则 +3. 同一邮箱配置两个自动化 → 冲突提示与 winner 预测与实际一致 +4. 编辑邮箱主机 → 确认游标重置、不重复处理历史邮件 +5. 删除被引用的邮箱 → 409 拦截提示 +6. 系统设置保存 IMAP + 测试连接 +7. 邮箱连接失败 → 通知中心出现提醒(10 分钟去抖) + +## 八、外部依赖(前置确认项) + +**ragent-service 需配合三件事**(模块 A): +1. system settings 支持 `imap_config` 读写 +2. `/api/v1/email/unread` 改用该配置 +3. 新增 IMAP 连接测试端点 + +若 ragent-service 无法配合,模块 A 降级为"完全迁入本仓库"方案:复用自定义邮箱的加密存储模式,为系统邮箱建立每租户一条的 `system` 记录,调度器 `system` 路径改走 `/api/v1/email/unread-config`。 + +## 九、待确认的语义问题(不阻塞本次实现) + +系统邮箱是**平台级共享**的(超管维护),但分组键与去重键均为 `userId:mailboxKey`。因此同一封进入系统邮箱的邮件,**不同用户的自动化可以各自触发一次,互不竞争优先级**。 + +- 现状语义:按用户隔离(与"自定义邮箱仅创建者本人"的决策一致)——本次实现**保持现状**。 +- 若期望"整个平台对同一封邮件只触发一个自动化",需将分组键与去重键改为不含 `userId`,影响面较大,另行评估。 +- 附带影响:N 个用户共用系统邮箱时,会各自独立拉取 IMAP(每 10 秒 N 次连接),大用户量下需关注。 + +## 十、建议实施顺序 + +按"先修地基、再做界面、最后接外部依赖"排列,每步可独立验证: + +1. **模块 D.5 规则逻辑去重**(抽 `mail-rules.ts` + 单测)——纯重构,无行为变更,为后续修改提供单一事实来源 +2. **模块 D.1/D.2/D.7 服务端校验与死代码清理**——不依赖任何 UI,独立可测 +3. **模块 B 向导内联邮箱配置**(含 D.6 冲突检测隔离)——主路径,用户价值最高 +4. **模块 C 轻量邮箱管理入口**(含 PUT 端点、游标重置/清理、D.3/D.4) +5. **模块 E 健壮性改进**(健康表、状态标记、通知派生、密钥前置条件、保留期) +6. **模块 A 系统邮箱 IMAP 配置**——依赖 ragent-service 配合,可与其他模块并行推进,但**须先确认外部依赖可行性** + +## 十一、验收标准 + +- 用户可在创建自动化时完成邮箱配置,无需离开向导 +- 邮箱配置错误在向导内即时反馈,不会产生静默失败的自动化 +- 多邮箱场景下冲突检测与优先级预测与实际调度行为一致 +- 邮箱连接失败可被用户感知(通知 + 状态标记),而非仅存在于服务端日志 +- 规则匹配逻辑单一来源,前后端行为不可能分叉 +- `pnpm test` 与 `pnpm check:ci` 通过 From 27a335e1e45aa251435573ecf25e4f43865d7912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 15:33:18 +0800 Subject: [PATCH 02/41] docs(automation): retire system mailbox, scope email triggers to own mailboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统邮箱退场:所有邮件触发自动化必须绑定用户自配邮箱。 消除跨用户重复触发风险,并移除 ragent-service 外部依赖。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 146 +++++++++++------- 1 file changed, 91 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index 5f96f9a..d2e4ac2 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -7,20 +7,24 @@ 邮件触发的执行链路(服务端调度器每 10 秒轮询邮箱 → 规则匹配 → 优先级竞争 → 去重 → 调用数字员工 → 策略分叉)**已经跑通**,但存在两类问题: -1. **能力已在后端、用户够不着**:自定义监听邮箱的后端(加密存储、真实 IMAP 连接验证、依赖检查删除)已完整实现,但前端完全没有入口,自动化向导写死"系统邮箱";系统邮箱的 IMAP 配置在平台内完全不可见。 +1. **能力已在后端、用户够不着**:自定义监听邮箱的后端(加密存储、真实 IMAP 连接验证、依赖检查删除)已完整实现,但前端完全没有入口,自动化向导写死"系统邮箱",用户无法配置自己的监听邮箱。 2. **已知缺陷**:服务端零校验、前端冲突检测未按邮箱隔离、连接失败无告警、匹配逻辑双份维护、存在死代码。 -本方案让用户在**创建自动化时直接配置监听邮箱**,把系统邮箱纳入平台系统设置,并修复上述缺陷。 +本方案让用户在**创建自动化时直接配置监听邮箱**,并**下线"系统邮箱"这一共享概念**(已确认决策),同时修复上述缺陷。 + +**关键收益**:系统邮箱退场后,每个邮箱都归属于唯一用户,分组键 `${userId}:${mailboxKey}` 不再可能跨用户碰撞——彻底消除了"同一封邮件被多个用户各自处理"的重复触发风险,且**不再需要 ragent-service 配合开发**(原方案的最大外部依赖)。 ## 二、范围 **纳入**: - 向导 step 2 内联配置监听邮箱(主路径) - 自动化页轻量邮箱管理入口(改密码 / 删除 / 看状态) -- 系统设置新增 IMAP 收信配置(需 ragent-service 配合) +- **系统邮箱下线** + 存量自动化迁移 - 服务端校验、冲突检测隔离、连接失败告警、规则逻辑去重、死代码清理 **不纳入(YAGNI)**: +- 系统设置页的 IMAP 收信配置(随系统邮箱退场一并取消) +- **SMTP 发信配置保持不动**:系统设置里的 `smtp_config` 用于发送结果通知邮件(`/api/v1/email/send`),与 IMAP 收信无关,本次不改 - 附件内容解析后传给 AI(现状只传附件名) - 部门/角色级邮箱共享(已确认:仅创建者本人) - 调度器多实例分布式锁(DB 唯一约束已防重复触发) @@ -42,38 +46,62 @@ | 创建邮箱接口保存前**真实连接 IMAP 验证**,失败即 400 | `pages/api/v1/automation-mailboxes/index.ts:52-63` | | 前端冲突检测与规则测试**未按邮箱隔离** | `page.tsx:663-681`、`683-754` | | `triggerDetail` 硬编码"系统邮箱" | `page.tsx:1292-1299`、`1101-1105` | -| 系统设置 SMTP 密码**明文回传前端** | `system-settings/page.tsx:195` | +| 邮件模板描述写死"系统邮箱收到新邮件" | `page.tsx:387`、`411` | +| 系统设置 SMTP 密码**明文回传前端**(本次不修改,仅记录) | `system-settings/page.tsx:195` | | 测试基建:`node --experimental-strip-types --test test/*.test.ts`,测试直接 import lib 纯函数 | `package.json:11`、`test/chatSse.test.ts` | +### 系统邮箱的完整触点清单(下线范围) + +| 位置 | 内容 | +|---|---| +| `automation-scheduler.ts:299-317` | `fetchSystemMailboxUnread`(调 `/api/v1/email/unread`) | +| `automation-scheduler.ts:324` | `if (mailboxKey === "system")` 分支 | +| `automation-scheduler.ts:357,376,377,456,554,567` | `"系统邮箱"` / `"system"` 兜底值 | +| `store.ts:725-726,875-876` | 创建/更新时的 `"system"` / `"系统邮箱"` 默认值 | +| `store.ts:1965,2011,2556,2580` | 底层函数的 `\|\| "system"` 兜底 | +| `store.ts:2412,2469-2470` | 展示层兜底标签 | +| `page.tsx:628,1105,1296,1298,1349-1350` | 写死的 `mailboxKey`/`mailboxLabel` | +| `page.tsx:3020-3059` | 向导的"固定监听系统邮箱"卡片 | +| `page.tsx:3762` | 运行详情邮箱显示兜底 | +| `page.tsx:387,411` | 模板文案 | +| `pages/api/v1/automation-email/claim.ts:15` | HTTP 端点的 `"system"` 兜底(该端点本身是死代码) | + +**下线后 `/api/v1/email/unread` 在本仓库将无任何调用方**(已核实仅 `check-email.ts` 与调度器 system 分支调用,两者都删)。ragent-service 侧该端点可保留,本平台不再调用。 + ## 四、模块设计 -### 模块 A:系统邮箱 IMAP 配置(系统设置页) +### 模块 A:系统邮箱下线与迁移 -系统邮箱是平台级共享邮箱,由超管维护,沿用现有 SMTP 配置的形态与权限模型。 +由于系统邮箱退场是**破坏性变更**,必须保证存量用户不静默失效。 -**ragent-service 侧(外部依赖,需配合)**: -1. system settings 对象新增 `imap_config` 字段(`IMAP_HOST` / `IMAP_PORT` / `IMAP_USERNAME` / `IMAP_PASSWORD` / `IMAP_USE_SSL` / `IMAP_FOLDER`),与现有 `smtp_config` 对称。 -2. `/api/v1/email/unread` 改为读取该配置。 -3. 新增 IMAP 连接测试端点。 +1. **存量迁移**(在 `ensureAutomationTables()` 中追加一次性迁移 SQL,与现有 `ALTER TABLE ... IF NOT EXISTS` 的迁移惯例一致): -**本仓库**: -- `app/system-settings/page.tsx`:在现有 SMTP 卡片旁新增"系统邮箱(IMAP 收信)" `CollapsibleCard`,含字段表单 + 保存 + "测试连接"按钮。保存复用现有 `PUT /api/system` 代理透传 `imap_config`。 -- `pages/api/system/index.ts`:透传 `imap_config`(与 `smtp_config` 同样处理)。 -- 新增 `pages/api/system/test-imap.ts`(转发 ragent-service 测试端点,需 super admin 校验)。 +```sql +UPDATE automation_tasks +SET status='paused', updated_at=NOW() +WHERE trigger_type='邮件触发' + AND status='running' + AND trigger_config->>'mailboxKey' = 'system'; +``` -**安全要求(不照抄 SMTP 的既有缺陷)**: -- 密码字段**只写**:接口只返回 `hasPassword` 布尔值,不回传密码明文。 -- 表单密码留空 = 不修改原值。 +该语句天然幂等(执行后不再有 running 的 system 任务)。 -**权限**:沿用页面现有 `checkSuperAdmin`。 +2. **用户可见提示**:暂停是静默的,必须让用户知道原因。 + - 自动化列表卡片 / 详情抽屉:对 `mailboxKey === 'system'` 的任务显示醒目提示条「系统邮箱已下线,请重新配置监听邮箱」+「立即配置」按钮(直接打开向导 step 2)。 + - 存量任务被暂停后状态徽标显示"已暂停",配合上述提示条解释原因。 + +3. **拒绝新建/更新为 system**:`mailboxKey` 不再接受 `"system"`,返回明确错误「系统邮箱已下线,请配置监听邮箱」。 + +4. **代码清理**:删除 `fetchSystemMailboxUnread` 与 `mailboxKey === "system"` 分支;`fetchConfiguredMailboxUnread` 简化为唯一路径。所有 `|| "system"` / `|| "系统邮箱"` 兜底改为显式校验并抛错(避免静默落入已下线分支)。 + +5. **保留防循环判断**(`automation-scheduler.ts:483`):结果邮件可能从用户自己的邮箱发出,主题前缀判断仍需保留。 ### 模块 B:向导内联邮箱配置(主路径) `app/automation/page.tsx` step 2 的邮件触发区块,把当前写死的"系统邮箱(固定监听)"卡片改为可选可配: ``` -监听邮箱 [ 系统邮箱(平台) ▾ ] - ├ 系统邮箱(平台) +监听邮箱 [ 销售部邮箱 sales@corp.com ▾ ] ├ 销售部邮箱 sales@corp.com ← 已保存的自定义邮箱(本用户) └ + 配置新邮箱… ← 选中后展开内联表单 IMAP 服务器 / 端口 / 账号 / 密码 / 文件夹 @@ -83,8 +111,10 @@ - 选中"+ 配置新邮箱…"展开内联表单;测试通过后调 `POST /api/v1/automation-mailboxes` 保存,从响应的 `key` 字段取得 `mailbox:` 写入本次自动化的 `mailboxKey`。 - 邮箱来源:`GET /api/v1/automation-mailboxes`(已存在)。 +- 无已保存邮箱时,下拉默认落在"+ 配置新邮箱…"并自动展开表单(避免空状态死路)。 - `buildAutomationPayload()` 的 `mailboxKey`/`mailboxLabel` 从选择派生,移除写死逻辑。 - `triggerDetail()`(`page.tsx:1292`)与 `localizedTriggerDetail`(`page.tsx:1101`)中的硬编码"系统邮箱"改为使用选中的邮箱标签。 +- 邮件模板描述(`page.tsx:387`、`411`)中的"系统邮箱"改为"监听邮箱"。 - `resetWizard()` / `openEditDialog()` / `useTemplate()` 同步处理邮箱字段。 **必须处理的三个陷阱**: @@ -97,28 +127,28 @@ 职责仅限"改密码 / 删除 / 看连接状态",与向导内联配置不重复。 - 自动化页头部新增次级入口按钮("邮箱管理")→ 抽屉组件 `app/automation/components/MailboxManager.tsx`,列表展示:名称 / 邮箱 / IMAP 服务器 / 状态徽标(`connected` / `error`)/ 最后错误。 -- **编辑(新增后端能力)**:`lib/automation/mailboxes.ts` 新增 `updateAutomationMailbox`(更新前同样真实连接验证一次);`pages/api/v1/automation-mailboxes/[id].ts` 新增 `PUT` 分支。 +- **编辑(新增后端能力)**:`lib/automation/mailboxes.ts` 新增 `updateAutomationMailbox`(更新前同样真实连接验证一次,密码留空则保留原值);`pages/api/v1/automation-mailboxes/[id].ts` 新增 `PUT` 分支。 - **删除**:复用现有 `DELETE`(已有 409 + dependents 依赖检查)。 - **编辑邮箱时必须重置游标**(见模块 D)。 - 新组件使用 next-intl `useTranslations` + `messages/` 文案,遵守 AGENTS.md 规范;不改动自动化页存量 `tt()` 文案。 ### 模块 D:服务端校验与一致性修复 -1. **mailboxKey 归属校验**:`store.ts` 的 `createAutomation`(725-727)与 `updateAutomation`(875-877)邮件分支中,若 `mailboxKey` 形如 `mailbox:`,调用 `getAutomationMailboxForUser(userId, id)` 校验归属,不存在则抛错。 -2. **mailboxLabel 服务端派生**:忽略客户端传入的 `mailboxLabel`,一律由邮箱记录派生(避免伪造显示)。`mailboxKey === "system"` 时固定为"系统邮箱",无对应邮箱表记录。 +1. **mailboxKey 归属校验**:`store.ts` 的 `createAutomation`(725-727)与 `updateAutomation`(875-877)邮件分支中,`mailboxKey` 必须形如 `mailbox:`,并调用 `getAutomationMailboxForUser(userId, id)` 校验归属,不存在或为 `system` 则抛错。 +2. **mailboxLabel 服务端派生**:忽略客户端传入的 `mailboxLabel`,一律由邮箱记录派生(避免伪造显示)。 3. **游标重置**:邮箱的 IMAP 主机 / 账号 / 文件夹变更后,UID 基线完全不同,必须将该 `mailboxKey` 的游标置为 `initialized=false`,否则会漏邮件或重复处理。 4. **删除邮箱清理游标**:`deleteAutomationMailbox` 中一并删除 `automation_email_mailbox_cursors` 对应行,避免 id 复用导致游标串号。 5. **规则逻辑去重**:抽取 `lib/automation/mail-rules.ts` 纯函数模块,承载 `doesMailRuleMatch` / `mailRulesSummary` / `mailRuleText` / 字段取值提取,以及规范化类型定义。调度器(`automation-scheduler.ts:242-297`)与前端测试器(`page.tsx:322-371`)均改为 import 该模块,消除双份维护。 6. **前端冲突检测按邮箱隔离**:`mailConflictCandidates`(`page.tsx:663-681`)与 `mailRuleTestResult`(`page.tsx:683-754`)的过滤条件增加 `mailboxKey` 相等判断,并随邮箱选择联动重算。**修复原因**:调度器只在同一 `userId:mailboxKey` 分组内做优先级竞争,前端跨邮箱比较会误报冲突并预测错误的 winner。 -7. **死代码清理**:删除 `pages/api/automation/check-email.ts`、`pages/api/automation/send-email.ts`(全仓库含文档均无引用)、`page.tsx:462` 的 `LEGACY_DEMO_AUTOMATION_NAMES`。 +7. **死代码清理**:删除 `pages/api/automation/check-email.ts`、`pages/api/automation/send-email.ts`、`pages/api/v1/automation-email/claim.ts`(全仓库含文档均无引用;调度器直接调用 store 函数,不经 HTTP)、`page.tsx:462` 的 `LEGACY_DEMO_AUTOMATION_NAMES`。 ### 模块 E:健壮性改进 -1. **连接失败告警**:现有通知机制是从 `automation_runs` / `automation_run_actions` **实时派生**的,没有通知表可插入,且邮箱连接失败时没有 run 记录。因此需新增一个健康状态数据源: - - 新表 `automation_mailbox_health(user_id, mailbox_key, last_error, last_error_at)`。 - - `listAutomationNotifications` 的派生逻辑新增第三段查询,eventKey 为 `mailbox::error`,kind 复用已有的 `email_failed`。 - - 带去抖:同一邮箱 10 分钟内最多产生一条提醒。 -2. **邮箱状态标记**:连接失败时更新状态为 `error` 并记录最后错误;成功后恢复 `connected`。模块 C 的列表展示该状态。 +1. **邮箱状态与告警**:`automation_mailboxes` 新增 `last_error` / `last_error_at` 列。连接失败时更新状态为 `error` 并记录错误;成功后恢复 `connected`。 +2. **连接失败通知**:现有通知机制是从 `automation_runs` / `automation_run_actions` **实时派生**的,没有通知表可插入,且邮箱连接失败时没有 run 记录。因此: + - `listAutomationNotifications` 的派生逻辑新增第三段查询,来源为 `automation_mailboxes` 中 `status='error'` 的记录,kind 复用已有的 `email_failed`。 + - **eventKey 固定为 `mailbox::error`(不含时间戳)**:因此邮箱恢复前该提醒只存在一条、状态稳定;邮箱恢复后提醒自动消失。前端的 toast 去重(`page.tsx:593` 的 `toastedNotificationKeysRef`,按 eventKey 去重)已能防止重复弹窗,无需额外的服务端去抖逻辑。 + - **注**:系统邮箱退场后所有邮箱都有 `automation_mailboxes` 行,因此无需新建独立的健康状态表。 3. **加密密钥前置条件**:`AUTOMATION_MAILBOX_SECRET` 当前不在 `env.example` 中,代码回退到 `JWT_SECRET`(`mailboxes.ts:51`)。**风险**:未显式配置时,轮换 `JWT_SECRET` 会导致所有已存邮箱密码永久无法解密(AES-GCM 认证失败)。处理:写入 `env.example` 并标注为部署前置条件;解密失败时返回明确错误码而非 500。 4. **去重表保留期**:`automation_email_processed_messages` 无界增长,增加定时清理(保留 30 天,随调度器每日执行一次)。 @@ -126,20 +156,23 @@ | 表 | 变更 | 用途 | |---|---|---| -| `automation_mailbox_health` | 新建 | 邮箱连接健康状态与最后错误(模块 E.1) | -| `automation_mailboxes` | 新增 `last_error` / `last_error_at` 列 | 邮箱状态展示(模块 E.2) | +| `automation_mailboxes` | 新增 `last_error` / `last_error_at` 列 | 邮箱连接状态与告警(模块 E.1) | +| `automation_tasks` | 一次性迁移:system 邮箱任务置为 paused | 系统邮箱下线(模块 A.1) | | `automation_email_mailbox_cursors` | 无结构变更 | 删除/编辑邮箱时清理或重置(模块 D.3、D.4) | +**无新增表。** 由于每个邮箱归属唯一用户,游标表与去重表的 `created_by_user_id` 键保持不变,无需 schema 变更。 + ## 六、API 变更 | 端点 | 变更 | |---|---| | `GET/POST /api/v1/automation-mailboxes` | 已存在,无变更 | -| `PUT /api/v1/automation-mailboxes/[id]` | **新增**(编辑,含真实连接验证、游标重置) | +| `PUT /api/v1/automation-mailboxes/[id]` | **新增**(编辑,含真实连接验证、密码留空保留原值、游标重置) | | `DELETE /api/v1/automation-mailboxes/[id]` | 已存在;增加游标清理 | -| `PUT /api/system` | 透传 `imap_config` | -| IMAP 测试代理端点 | **新增**(super admin 校验) | -| `POST/PUT /api/v1/automations` | 增加 mailboxKey 归属校验、label 服务端派生 | +| `POST/PUT /api/v1/automations` | 增加 mailboxKey 归属校验、拒绝 system、label 服务端派生 | +| `GET /api/v1/automation-email/stats` | 无变更(前端在用) | +| `POST /api/v1/automation-email/claim` | **删除**(死代码) | +| `/api/system` 的 `smtp_config` | **不变**(发信,与本次无关) | ## 七、测试与验收 @@ -149,40 +182,42 @@ **手工验收**: 1. 向导内联配置邮箱(正确凭据 / 错误密码两条路径)→ 保存 → 自动化创建成功 -2. 向该邮箱发测试邮件 → 触发运行 → 运行详情显示邮件来源与命中规则 -3. 同一邮箱配置两个自动化 → 冲突提示与 winner 预测与实际一致 -4. 编辑邮箱主机 → 确认游标重置、不重复处理历史邮件 -5. 删除被引用的邮箱 → 409 拦截提示 -6. 系统设置保存 IMAP + 测试连接 -7. 邮箱连接失败 → 通知中心出现提醒(10 分钟去抖) +2. 无已保存邮箱时,向导默认展开"配置新邮箱"表单 +3. 向该邮箱发测试邮件 → 触发运行 → 运行详情显示邮件来源与命中规则 +4. 同一邮箱配置两个自动化 → 冲突提示与 winner 预测与实际一致 +5. 不同邮箱的两个自动化 → **不产生**冲突提示 +6. 编辑邮箱主机 → 确认游标重置、不重复处理历史邮件 +7. 删除被引用的邮箱 → 409 拦截提示 +8. 尝试创建 `mailboxKey="system"` 的自动化 → 被拒绝并提示 +9. 存量 system 自动化 → 启动后为 paused 状态,列表显示"系统邮箱已下线"提示条 +10. 邮箱连接失败 → 通知中心出现提醒(10 分钟去抖)+ 状态标记为 error -## 八、外部依赖(前置确认项) +## 八、外部依赖 -**ragent-service 需配合三件事**(模块 A): -1. system settings 支持 `imap_config` 读写 -2. `/api/v1/email/unread` 改用该配置 -3. 新增 IMAP 连接测试端点 +**无。** 系统邮箱退场后,平台不再需要 ragent-service 新增 `imap_config` 读写、IMAP 测试端点,也不需要修改 `/api/v1/email/unread`。 -若 ragent-service 无法配合,模块 A 降级为"完全迁入本仓库"方案:复用自定义邮箱的加密存储模式,为系统邮箱建立每租户一条的 `system` 记录,调度器 `system` 路径改走 `/api/v1/email/unread-config`。 +本次仅依赖 ragent-service 已有的两个端点,均在现有代码中正常使用: +- `POST /api/v1/email/unread-config`(自定义邮箱收信,`mailbox-client.ts` 已在用) +- `POST /api/v1/email/send`(结果邮件发送,`actions.ts` 已在用,本次不改) -## 九、待确认的语义问题(不阻塞本次实现) +## 九、已决策事项 -系统邮箱是**平台级共享**的(超管维护),但分组键与去重键均为 `userId:mailboxKey`。因此同一封进入系统邮箱的邮件,**不同用户的自动化可以各自触发一次,互不竞争优先级**。 +**系统邮箱退场**(2026-09-11 确认):不再提供平台级共享监听邮箱,所有邮件触发自动化必须绑定用户自己配置的邮箱。 -- 现状语义:按用户隔离(与"自定义邮箱仅创建者本人"的决策一致)——本次实现**保持现状**。 -- 若期望"整个平台对同一封邮件只触发一个自动化",需将分组键与去重键改为不含 `userId`,影响面较大,另行评估。 -- 附带影响:N 个用户共用系统邮箱时,会各自独立拉取 IMAP(每 10 秒 N 次连接),大用户量下需关注。 +- **理由**:原设计下系统邮箱为平台级共享但分组按用户隔离,同一封邮件会被 N 个用户各自的自动化各触发一次(例如三人各建"客户询价处理"→ 一封询价被处理 3 次,可能重复回信或重复建单)。 +- **取舍**:失去了"管理员配置一次、全员可用"的便利,非技术用户需要自己提供企业邮箱授权码。这是有意的选择。 +- **迁移**:存量 system 自动化被置为 paused 并给出 UI 提示(模块 A)。 ## 十、建议实施顺序 -按"先修地基、再做界面、最后接外部依赖"排列,每步可独立验证: +按"先修地基、再做界面、最后下线共享概念"排列,每步可独立验证: 1. **模块 D.5 规则逻辑去重**(抽 `mail-rules.ts` + 单测)——纯重构,无行为变更,为后续修改提供单一事实来源 2. **模块 D.1/D.2/D.7 服务端校验与死代码清理**——不依赖任何 UI,独立可测 3. **模块 B 向导内联邮箱配置**(含 D.6 冲突检测隔离)——主路径,用户价值最高 4. **模块 C 轻量邮箱管理入口**(含 PUT 端点、游标重置/清理、D.3/D.4) -5. **模块 E 健壮性改进**(健康表、状态标记、通知派生、密钥前置条件、保留期) -6. **模块 A 系统邮箱 IMAP 配置**——依赖 ragent-service 配合,可与其他模块并行推进,但**须先确认外部依赖可行性** +5. **模块 E 健壮性改进**(状态列、通知派生、密钥前置条件、保留期) +6. **模块 A 系统邮箱下线与迁移**——**放在最后**:等自定义邮箱配置路径完全可用后再下线,避免出现"旧路已断、新路未通"的空窗 ## 十一、验收标准 @@ -191,4 +226,5 @@ - 多邮箱场景下冲突检测与优先级预测与实际调度行为一致 - 邮箱连接失败可被用户感知(通知 + 状态标记),而非仅存在于服务端日志 - 规则匹配逻辑单一来源,前后端行为不可能分叉 +- 系统邮箱下线后,存量用户通过暂停状态 + 提示条明确知道需要重新配置 - `pnpm test` 与 `pnpm check:ci` 通过 From 9d9c592b550a933fce9f45d3fc60d08de408deee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 15:50:40 +0800 Subject: [PATCH 03/41] docs(automation): delete legacy system-mailbox automations on migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存量处置确认为直接删除,不做暂停过渡、不保留兼容路径。 补充关联数据清理、运行历史保留说明与发布要求。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index d2e4ac2..cb0ba45 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -19,7 +19,7 @@ **纳入**: - 向导 step 2 内联配置监听邮箱(主路径) - 自动化页轻量邮箱管理入口(改密码 / 删除 / 看状态) -- **系统邮箱下线** + 存量自动化迁移 +- **系统邮箱下线** + 存量 system 自动化删除 - 服务端校验、冲突检测隔离、连接失败告警、规则逻辑去重、死代码清理 **不纳入(YAGNI)**: @@ -70,31 +70,34 @@ ## 四、模块设计 -### 模块 A:系统邮箱下线与迁移 +### 模块 A:系统邮箱下线与存量删除 -由于系统邮箱退场是**破坏性变更**,必须保证存量用户不静默失效。 +系统邮箱退场是**破坏性变更**。已确认:**存量 system 自动化直接删除**,不做暂停过渡、不保留任何兼容路径。 -1. **存量迁移**(在 `ensureAutomationTables()` 中追加一次性迁移 SQL,与现有 `ALTER TABLE ... IF NOT EXISTS` 的迁移惯例一致): +1. **存量删除**(在 `ensureAutomationTables()` 中追加一次性迁移,与现有 `ALTER TABLE ... IF NOT EXISTS` 的迁移惯例一致): ```sql -UPDATE automation_tasks -SET status='paused', updated_at=NOW() +DELETE FROM automation_tasks WHERE trigger_type='邮件触发' - AND status='running' AND trigger_config->>'mailboxKey' = 'system'; ``` -该语句天然幂等(执行后不再有 running 的 system 任务)。 +约束与安全要求: +- WHERE 条件严格限定 `trigger_type='邮件触发'` 且 `mailboxKey` **等于** `'system'`——等于判断不会匹配 NULL,确保不触及自定义邮箱自动化与其他触发类型。 +- 实施时先 `SELECT count(*)` 记录待删条数并打日志,再执行 DELETE,最后记录实际删除条数,便于事后核对。这是本方案中唯一的破坏性语句,实现时需重点 review。 +- 天然幂等:首次执行后不再有匹配行。 -2. **用户可见提示**:暂停是静默的,必须让用户知道原因。 - - 自动化列表卡片 / 详情抽屉:对 `mailboxKey === 'system'` 的任务显示醒目提示条「系统邮箱已下线,请重新配置监听邮箱」+「立即配置」按钮(直接打开向导 step 2)。 - - 存量任务被暂停后状态徽标显示"已暂停",配合上述提示条解释原因。 +2. **关联数据清理**:同时清理 `automation_email_mailbox_cursors`、`automation_email_processed_messages`、`automation_email_rule_events` 中 `mailbox_key='system'` 的历史行——这些键在新模型下永不再被写入。 -3. **拒绝新建/更新为 system**:`mailboxKey` 不再接受 `"system"`,返回明确错误「系统邮箱已下线,请配置监听邮箱」。 +3. **运行历史保留**:`automation_runs` 表无外键级联(`deleteAutomation` 同样只删除任务行),历史运行记录不受影响。 -4. **代码清理**:删除 `fetchSystemMailboxUnread` 与 `mailboxKey === "system"` 分支;`fetchConfiguredMailboxUnread` 简化为唯一路径。所有 `|| "system"` / `|| "系统邮箱"` 兜底改为显式校验并抛错(避免静默落入已下线分支)。 +4. **拒绝新建/更新为 system**:`mailboxKey` 不再接受 `"system"`,返回明确错误「系统邮箱已下线,请配置监听邮箱」。 -5. **保留防循环判断**(`automation-scheduler.ts:483`):结果邮件可能从用户自己的邮箱发出,主题前缀判断仍需保留。 +5. **代码清理**:删除 `fetchSystemMailboxUnread` 与 `mailboxKey === "system"` 分支;`fetchConfiguredMailboxUnread` 简化为唯一路径。所有 `|| "system"` / `|| "系统邮箱"` 兜底改为显式校验并抛错(避免静默落入已下线分支)。 + +6. **发布要求**:删除必须与模块 B/C **同一次发布**上线——存量任务被删除后,用户需要新的配置路径才能重建自动化。需在发布说明中明确告知:**该变更会导致存量邮件触发自动化被删除,用户需重新创建**。 + +7. **保留防循环判断**(`automation-scheduler.ts:483`):结果邮件可能从用户自己的邮箱发出,主题前缀判断仍需保留。 ### 模块 B:向导内联邮箱配置(主路径) @@ -157,8 +160,10 @@ WHERE trigger_type='邮件触发' | 表 | 变更 | 用途 | |---|---|---| | `automation_mailboxes` | 新增 `last_error` / `last_error_at` 列 | 邮箱连接状态与告警(模块 E.1) | -| `automation_tasks` | 一次性迁移:system 邮箱任务置为 paused | 系统邮箱下线(模块 A.1) | -| `automation_email_mailbox_cursors` | 无结构变更 | 删除/编辑邮箱时清理或重置(模块 D.3、D.4) | +| `automation_tasks` | 一次性迁移:**删除** system 邮箱任务 | 系统邮箱下线(模块 A.1) | +| `automation_email_mailbox_cursors` | 无结构变更 | 删除/编辑邮箱时清理或重置(模块 D.3、D.4);清理 `mailbox_key='system'` 历史行(模块 A.2) | +| `automation_email_processed_messages` | 无结构变更 | 清理 `mailbox_key='system'` 历史行(模块 A.2);30 天保留期(模块 E.4) | +| `automation_email_rule_events` | 无结构变更 | 清理 `mailbox_key='system'` 历史行(模块 A.2) | **无新增表。** 由于每个邮箱归属唯一用户,游标表与去重表的 `created_by_user_id` 键保持不变,无需 schema 变更。 @@ -189,7 +194,8 @@ WHERE trigger_type='邮件触发' 6. 编辑邮箱主机 → 确认游标重置、不重复处理历史邮件 7. 删除被引用的邮箱 → 409 拦截提示 8. 尝试创建 `mailboxKey="system"` 的自动化 → 被拒绝并提示 -9. 存量 system 自动化 → 启动后为 paused 状态,列表显示"系统邮箱已下线"提示条 +9. 存量 system 自动化 → 迁移后从列表消失,**其他触发类型的自动化与自定义邮箱自动化均不受影响**(这是删除语句的关键回归点) +10. 迁移后 `mailbox_key='system'` 的游标/去重/路由事件行全部清空 10. 邮箱连接失败 → 通知中心出现提醒(10 分钟去抖)+ 状态标记为 error ## 八、外部依赖 @@ -206,7 +212,7 @@ WHERE trigger_type='邮件触发' - **理由**:原设计下系统邮箱为平台级共享但分组按用户隔离,同一封邮件会被 N 个用户各自的自动化各触发一次(例如三人各建"客户询价处理"→ 一封询价被处理 3 次,可能重复回信或重复建单)。 - **取舍**:失去了"管理员配置一次、全员可用"的便利,非技术用户需要自己提供企业邮箱授权码。这是有意的选择。 -- **迁移**:存量 system 自动化被置为 paused 并给出 UI 提示(模块 A)。 +- **存量处置**:已确认**直接删除**(不做暂停过渡、不保留兼容路径)。运行历史保留,用户需重新创建自动化(模块 A)。 ## 十、建议实施顺序 @@ -217,7 +223,7 @@ WHERE trigger_type='邮件触发' 3. **模块 B 向导内联邮箱配置**(含 D.6 冲突检测隔离)——主路径,用户价值最高 4. **模块 C 轻量邮箱管理入口**(含 PUT 端点、游标重置/清理、D.3/D.4) 5. **模块 E 健壮性改进**(状态列、通知派生、密钥前置条件、保留期) -6. **模块 A 系统邮箱下线与迁移**——**放在最后**:等自定义邮箱配置路径完全可用后再下线,避免出现"旧路已断、新路未通"的空窗 +6. **模块 A 系统邮箱下线与存量删除**——**放在最后**,且必须与模块 B/C **同一次发布**:删除后用户需要新的配置路径才能重建自动化,不能出现"旧路已断、新路未通"的空窗 ## 十一、验收标准 @@ -226,5 +232,5 @@ WHERE trigger_type='邮件触发' - 多邮箱场景下冲突检测与优先级预测与实际调度行为一致 - 邮箱连接失败可被用户感知(通知 + 状态标记),而非仅存在于服务端日志 - 规则匹配逻辑单一来源,前后端行为不可能分叉 -- 系统邮箱下线后,存量用户通过暂停状态 + 提示条明确知道需要重新配置 +- 系统邮箱下线后,代码中不再存在任何 system 分支或 `\|\| "system"` 兜底;存量 system 自动化被准确删除且未误伤其他任务 - `pnpm test` 与 `pnpm check:ci` 通过 From b99e3b9eb31bd3c4719678e00a9aacaf9fd99fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 15:53:24 +0800 Subject: [PATCH 04/41] docs(automation): system mailbox never used, drop migration ceremony MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能从未被实际使用,无存量数据、无需发布说明与备份, 迁移语句降级为幂等防御性清理(预期 0 行)。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index cb0ba45..9fc3365 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -19,7 +19,7 @@ **纳入**: - 向导 step 2 内联配置监听邮箱(主路径) - 自动化页轻量邮箱管理入口(改密码 / 删除 / 看状态) -- **系统邮箱下线** + 存量 system 自动化删除 +- **系统邮箱下线**(该功能从未被实际使用,无存量迁移负担) - 服务端校验、冲突检测隔离、连接失败告警、规则逻辑去重、死代码清理 **不纳入(YAGNI)**: @@ -70,11 +70,11 @@ ## 四、模块设计 -### 模块 A:系统邮箱下线与存量删除 +### 模块 A:系统邮箱下线 -系统邮箱退场是**破坏性变更**。已确认:**存量 system 自动化直接删除**,不做暂停过渡、不保留任何兼容路径。 +系统邮箱退场是破坏性变更,但**该功能从未被实际使用**(已确认),因此**无需发布说明、无需备份、无需迁移公告**,也没有存量兼容负担。 -1. **存量删除**(在 `ensureAutomationTables()` 中追加一次性迁移,与现有 `ALTER TABLE ... IF NOT EXISTS` 的迁移惯例一致): +1. **防御性清理**(在 `ensureAutomationTables()` 中追加一条幂等清理语句): ```sql DELETE FROM automation_tasks @@ -82,22 +82,19 @@ WHERE trigger_type='邮件触发' AND trigger_config->>'mailboxKey' = 'system'; ``` -约束与安全要求: -- WHERE 条件严格限定 `trigger_type='邮件触发'` 且 `mailboxKey` **等于** `'system'`——等于判断不会匹配 NULL,确保不触及自定义邮箱自动化与其他触发类型。 -- 实施时先 `SELECT count(*)` 记录待删条数并打日志,再执行 DELETE,最后记录实际删除条数,便于事后核对。这是本方案中唯一的破坏性语句,实现时需重点 review。 -- 天然幂等:首次执行后不再有匹配行。 +- WHERE 严格限定 `trigger_type='邮件触发'` 且 `mailboxKey` **等于** `'system'`——等于判断不匹配 NULL,确保不误伤其他触发类型的自动化。 +- **预期影响 0 行**。保留这条语句是防御性的:万一实际存在残留行,避免其在新代码下每 10 秒产生一次调度错误日志。天然幂等。 +- 这是本方案唯一的破坏性语句,仍需在 review 时确认 WHERE 范围(防的是误伤其他任务,与存量数据无关)。 -2. **关联数据清理**:同时清理 `automation_email_mailbox_cursors`、`automation_email_processed_messages`、`automation_email_rule_events` 中 `mailbox_key='system'` 的历史行——这些键在新模型下永不再被写入。 +2. **关联数据清理**:同样清理 `automation_email_mailbox_cursors`、`automation_email_processed_messages`、`automation_email_rule_events` 中 `mailbox_key='system'` 的行(预期同为 0 行)。 -3. **运行历史保留**:`automation_runs` 表无外键级联(`deleteAutomation` 同样只删除任务行),历史运行记录不受影响。 +3. **运行历史**:`automation_runs` 表无外键级联,即便有历史运行记录也不受影响。 4. **拒绝新建/更新为 system**:`mailboxKey` 不再接受 `"system"`,返回明确错误「系统邮箱已下线,请配置监听邮箱」。 5. **代码清理**:删除 `fetchSystemMailboxUnread` 与 `mailboxKey === "system"` 分支;`fetchConfiguredMailboxUnread` 简化为唯一路径。所有 `|| "system"` / `|| "系统邮箱"` 兜底改为显式校验并抛错(避免静默落入已下线分支)。 -6. **发布要求**:删除必须与模块 B/C **同一次发布**上线——存量任务被删除后,用户需要新的配置路径才能重建自动化。需在发布说明中明确告知:**该变更会导致存量邮件触发自动化被删除,用户需重新创建**。 - -7. **保留防循环判断**(`automation-scheduler.ts:483`):结果邮件可能从用户自己的邮箱发出,主题前缀判断仍需保留。 +6. **保留防循环判断**(`automation-scheduler.ts:483`):结果邮件可能从用户自己的邮箱发出,主题前缀判断仍需保留。 ### 模块 B:向导内联邮箱配置(主路径) @@ -160,7 +157,7 @@ WHERE trigger_type='邮件触发' | 表 | 变更 | 用途 | |---|---|---| | `automation_mailboxes` | 新增 `last_error` / `last_error_at` 列 | 邮箱连接状态与告警(模块 E.1) | -| `automation_tasks` | 一次性迁移:**删除** system 邮箱任务 | 系统邮箱下线(模块 A.1) | +| `automation_tasks` | 幂等防御性清理:删除 system 邮箱任务(预期 0 行) | 系统邮箱下线(模块 A.1) | | `automation_email_mailbox_cursors` | 无结构变更 | 删除/编辑邮箱时清理或重置(模块 D.3、D.4);清理 `mailbox_key='system'` 历史行(模块 A.2) | | `automation_email_processed_messages` | 无结构变更 | 清理 `mailbox_key='system'` 历史行(模块 A.2);30 天保留期(模块 E.4) | | `automation_email_rule_events` | 无结构变更 | 清理 `mailbox_key='system'` 历史行(模块 A.2) | @@ -194,8 +191,7 @@ WHERE trigger_type='邮件触发' 6. 编辑邮箱主机 → 确认游标重置、不重复处理历史邮件 7. 删除被引用的邮箱 → 409 拦截提示 8. 尝试创建 `mailboxKey="system"` 的自动化 → 被拒绝并提示 -9. 存量 system 自动化 → 迁移后从列表消失,**其他触发类型的自动化与自定义邮箱自动化均不受影响**(这是删除语句的关键回归点) -10. 迁移后 `mailbox_key='system'` 的游标/去重/路由事件行全部清空 +9. 清理语句执行后,**其他触发类型的自动化与自定义邮箱自动化均不受影响**(这是删除语句的关键回归点) 10. 邮箱连接失败 → 通知中心出现提醒(10 分钟去抖)+ 状态标记为 error ## 八、外部依赖 @@ -212,7 +208,7 @@ WHERE trigger_type='邮件触发' - **理由**:原设计下系统邮箱为平台级共享但分组按用户隔离,同一封邮件会被 N 个用户各自的自动化各触发一次(例如三人各建"客户询价处理"→ 一封询价被处理 3 次,可能重复回信或重复建单)。 - **取舍**:失去了"管理员配置一次、全员可用"的便利,非技术用户需要自己提供企业邮箱授权码。这是有意的选择。 -- **存量处置**:已确认**直接删除**(不做暂停过渡、不保留兼容路径)。运行历史保留,用户需重新创建自动化(模块 A)。 +- **存量处置**:**该功能从未被实际使用**(已确认),因此无存量数据、无兼容负担、无需发布说明或备份。仅保留一条防御性清理语句(模块 A)。 ## 十、建议实施顺序 @@ -223,7 +219,7 @@ WHERE trigger_type='邮件触发' 3. **模块 B 向导内联邮箱配置**(含 D.6 冲突检测隔离)——主路径,用户价值最高 4. **模块 C 轻量邮箱管理入口**(含 PUT 端点、游标重置/清理、D.3/D.4) 5. **模块 E 健壮性改进**(状态列、通知派生、密钥前置条件、保留期) -6. **模块 A 系统邮箱下线与存量删除**——**放在最后**,且必须与模块 B/C **同一次发布**:删除后用户需要新的配置路径才能重建自动化,不能出现"旧路已断、新路未通"的空窗 +6. **模块 A 系统邮箱下线**——放在最后,与模块 B/C 同一次发布(无存量数据,仅为保持"新路先通、再断旧路"的稳妥顺序) ## 十一、验收标准 From 2c02ef9b42103560122f7a6d8a105ec38e8a820f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 15:55:06 +0800 Subject: [PATCH 05/41] docs(automation): unify mailbox key to integer mailboxId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 趁三张邮件表为空,将 mailbox: 字符串编码改为整数 mailboxId, 拆除为 system 服务的编码脚手架。修正迁移语句顺序: 遗留数据清理必须先于列类型变更,否则非数字值会导致 ALTER 失败。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 83 ++++++++++++------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index 9fc3365..09a8df6 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -12,7 +12,7 @@ 本方案让用户在**创建自动化时直接配置监听邮箱**,并**下线"系统邮箱"这一共享概念**(已确认决策),同时修复上述缺陷。 -**关键收益**:系统邮箱退场后,每个邮箱都归属于唯一用户,分组键 `${userId}:${mailboxKey}` 不再可能跨用户碰撞——彻底消除了"同一封邮件被多个用户各自处理"的重复触发风险,且**不再需要 ragent-service 配合开发**(原方案的最大外部依赖)。 +**关键收益**:系统邮箱退场后,每个邮箱都归属于唯一用户,分组键 `${userId}:${mailboxId}` 不再可能跨用户碰撞——彻底消除了"同一封邮件被多个用户各自处理"的重复触发风险,且**不再需要 ragent-service 配合开发**(原方案的最大外部依赖)。同时,由于功能从未使用、三张邮件表为空,可以趁此窗口把 `mailbox:` 字符串键统一为整数 `mailboxId`,拆掉为"system"服务的编码脚手架。 ## 二、范围 @@ -87,6 +87,7 @@ WHERE trigger_type='邮件触发' - 这是本方案唯一的破坏性语句,仍需在 review 时确认 WHERE 范围(防的是误伤其他任务,与存量数据无关)。 2. **关联数据清理**:同样清理 `automation_email_mailbox_cursors`、`automation_email_processed_messages`、`automation_email_rule_events` 中 `mailbox_key='system'` 的行(预期同为 0 行)。 + **执行时机**:这三张表的清理**必须在模块 D.1 的列类型变更之前执行**(见 D.1 的顺序要求)——`system` 无法转换为整数,顺序颠倒会导致迁移失败。因此本模块的 SQL 虽然在功能上属于"下线",其数据清理语句需随 D.1 一起、排在其前面进入 `ensureAutomationTables()`。 3. **运行历史**:`automation_runs` 表无外键级联,即便有历史运行记录也不受影响。 @@ -109,10 +110,10 @@ WHERE trigger_type='邮件触发' ⚠ 该邮箱已被 N 个自动化使用,修改凭据会影响它们 ``` -- 选中"+ 配置新邮箱…"展开内联表单;测试通过后调 `POST /api/v1/automation-mailboxes` 保存,从响应的 `key` 字段取得 `mailbox:` 写入本次自动化的 `mailboxKey`。 +- 选中"+ 配置新邮箱…"展开内联表单;测试通过后调 `POST /api/v1/automation-mailboxes` 保存,从响应的 `id` 写入本次自动化的 `mailboxId`。 - 邮箱来源:`GET /api/v1/automation-mailboxes`(已存在)。 - 无已保存邮箱时,下拉默认落在"+ 配置新邮箱…"并自动展开表单(避免空状态死路)。 -- `buildAutomationPayload()` 的 `mailboxKey`/`mailboxLabel` 从选择派生,移除写死逻辑。 +- `buildAutomationPayload()` 的 `mailboxId`/`mailboxLabel` 从选择派生,移除写死逻辑。 - `triggerDetail()`(`page.tsx:1292`)与 `localizedTriggerDetail`(`page.tsx:1101`)中的硬编码"系统邮箱"改为使用选中的邮箱标签。 - 邮件模板描述(`page.tsx:387`、`411`)中的"系统邮箱"改为"监听邮箱"。 - `resetWizard()` / `openEditDialog()` / `useTemplate()` 同步处理邮箱字段。 @@ -132,22 +133,41 @@ WHERE trigger_type='邮件触发' - **编辑邮箱时必须重置游标**(见模块 D)。 - 新组件使用 next-intl `useTranslations` + `messages/` 文案,遵守 AGENTS.md 规范;不改动自动化页存量 `tt()` 文案。 -### 模块 D:服务端校验与一致性修复 +### 模块 D:数据一致性与服务端校验 -1. **mailboxKey 归属校验**:`store.ts` 的 `createAutomation`(725-727)与 `updateAutomation`(875-877)邮件分支中,`mailboxKey` 必须形如 `mailbox:`,并调用 `getAutomationMailboxForUser(userId, id)` 校验归属,不存在或为 `system` 则抛错。 -2. **mailboxLabel 服务端派生**:忽略客户端传入的 `mailboxLabel`,一律由邮箱记录派生(避免伪造显示)。 -3. **游标重置**:邮箱的 IMAP 主机 / 账号 / 文件夹变更后,UID 基线完全不同,必须将该 `mailboxKey` 的游标置为 `initialized=false`,否则会漏邮件或重复处理。 -4. **删除邮箱清理游标**:`deleteAutomationMailbox` 中一并删除 `automation_email_mailbox_cursors` 对应行,避免 id 复用导致游标串号。 -5. **规则逻辑去重**:抽取 `lib/automation/mail-rules.ts` 纯函数模块,承载 `doesMailRuleMatch` / `mailRulesSummary` / `mailRuleText` / 字段取值提取,以及规范化类型定义。调度器(`automation-scheduler.ts:242-297`)与前端测试器(`page.tsx:322-371`)均改为 import 该模块,消除双份维护。 -6. **前端冲突检测按邮箱隔离**:`mailConflictCandidates`(`page.tsx:663-681`)与 `mailRuleTestResult`(`page.tsx:683-754`)的过滤条件增加 `mailboxKey` 相等判断,并随邮箱选择联动重算。**修复原因**:调度器只在同一 `userId:mailboxKey` 分组内做优先级竞争,前端跨邮箱比较会误报冲突并预测错误的 winner。 -7. **死代码清理**:删除 `pages/api/automation/check-email.ts`、`pages/api/automation/send-email.ts`、`pages/api/v1/automation-email/claim.ts`(全仓库含文档均无引用;调度器直接调用 store 函数,不经 HTTP)、`page.tsx:462` 的 `LEGACY_DEMO_AUTOMATION_NAMES`。 +**D.1 键格式统一为 mailboxId(数据模型改造,本模块其余各项与其他模块的前提)** + +`mailbox:` 字符串编码的唯一存在理由是让 `"system"` 能成为一个合法值。system 退场后,每个键都只是被编码成字符串的整数,这层编码成为纯粹累赘。趁三张表均为空(功能从未使用)一次改到位: + +- `trigger_config.mailboxKey: "mailbox:12"` → `trigger_config.mailboxId: 12`(整数) +- 三张表的 `mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`:`automation_email_mailbox_cursors`(主键)、`automation_email_processed_messages`(唯一键)、`automation_email_rule_events`(唯一键) +- 删除调度器的 `mailboxIdFromKey()` 正则解析;分组键由 `${userId}:${mailboxKey}` 改为 `${userId}:${mailboxId}` +- 邮箱记录 API 的 `key: "mailbox:${id}"` 字段取消,直接暴露 `id` + +**注**:表为空,列类型变更安全。`ensureAutomationTables()` 使用 `CREATE TABLE IF NOT EXISTS`,不会修改已存在的表结构,因此需要显式 `ALTER TABLE ... RENAME COLUMN` + `ALTER COLUMN ... TYPE INTEGER` 语句(同函数内已有 `ALTER TABLE ADD COLUMN IF NOT EXISTS` 的迁移先例)。 + +> ⚠️ **SQL 语句顺序要求**:模块 A.2 的遗留数据清理(删除 `mailbox_key='system'` 行)**必须排在本节的列类型变更之前**。`system` 是非数字字符串,若先执行 `ALTER COLUMN ... TYPE INTEGER`,PostgreSQL 会在这些行上转换失败并中止迁移。`ensureAutomationTables()` 内的语句顺序应为:建表 → 清理遗留 system 行 → 列重命名与类型变更。 + +**D.2 mailboxId 归属校验**:`store.ts` 的 `createAutomation`(725-727)与 `updateAutomation`(875-877)邮件分支中,`mailboxId` 必须是正整数,并调用 `getAutomationMailboxForUser(userId, mailboxId)` 校验归属,不存在则抛错(不再有 `system` 特例)。 + +**D.3 mailboxLabel 服务端派生**:忽略客户端传入的 `mailboxLabel`,一律由邮箱记录派生(避免伪造显示)。 + +**D.4 游标重置**:邮箱的 IMAP 主机 / 账号 / 文件夹变更后,UID 基线完全不同,必须将该邮箱的游标置为 `initialized=false`,否则会漏邮件或重复处理。 + +**D.5 删除邮箱清理游标**:`deleteAutomationMailbox` 中一并删除 `automation_email_mailbox_cursors` 对应行,避免 id 复用导致游标串号。 + +**D.6 规则逻辑去重**:抽取 `lib/automation/mail-rules.ts` 纯函数模块,承载 `doesMailRuleMatch` / `mailRulesSummary` / `mailRuleText` / 字段取值提取,以及规范化类型定义。调度器(`automation-scheduler.ts:242-297`)与前端测试器(`page.tsx:322-371`)均改为 import 该模块,消除双份维护。 + +**D.7 前端冲突检测按邮箱隔离**:`mailConflictCandidates`(`page.tsx:663-681`)与 `mailRuleTestResult`(`page.tsx:683-754`)的过滤条件增加 `mailboxId` 相等判断,并随邮箱选择联动重算。**修复原因**:调度器只在同一 `userId:mailboxId` 分组内做优先级竞争,前端跨邮箱比较会误报冲突并预测错误的 winner。 + +**D.8 死代码清理**:删除 `pages/api/automation/check-email.ts`、`pages/api/automation/send-email.ts`、`pages/api/v1/automation-email/claim.ts`(全仓库含文档均无引用;调度器直接调用 store 函数,不经 HTTP)、`page.tsx:462` 的 `LEGACY_DEMO_AUTOMATION_NAMES`。 ### 模块 E:健壮性改进 1. **邮箱状态与告警**:`automation_mailboxes` 新增 `last_error` / `last_error_at` 列。连接失败时更新状态为 `error` 并记录错误;成功后恢复 `connected`。 2. **连接失败通知**:现有通知机制是从 `automation_runs` / `automation_run_actions` **实时派生**的,没有通知表可插入,且邮箱连接失败时没有 run 记录。因此: - `listAutomationNotifications` 的派生逻辑新增第三段查询,来源为 `automation_mailboxes` 中 `status='error'` 的记录,kind 复用已有的 `email_failed`。 - - **eventKey 固定为 `mailbox::error`(不含时间戳)**:因此邮箱恢复前该提醒只存在一条、状态稳定;邮箱恢复后提醒自动消失。前端的 toast 去重(`page.tsx:593` 的 `toastedNotificationKeysRef`,按 eventKey 去重)已能防止重复弹窗,无需额外的服务端去抖逻辑。 + - **eventKey 固定为 `mailbox-error:${mailboxId}`(不含时间戳)**:因此邮箱恢复前该提醒只存在一条、状态稳定;邮箱恢复后提醒自动消失。前端的 toast 去重(`page.tsx:593` 的 `toastedNotificationKeysRef`,按 eventKey 去重)已能防止重复弹窗,无需额外的服务端去抖逻辑。 - **注**:系统邮箱退场后所有邮箱都有 `automation_mailboxes` 行,因此无需新建独立的健康状态表。 3. **加密密钥前置条件**:`AUTOMATION_MAILBOX_SECRET` 当前不在 `env.example` 中,代码回退到 `JWT_SECRET`(`mailboxes.ts:51`)。**风险**:未显式配置时,轮换 `JWT_SECRET` 会导致所有已存邮箱密码永久无法解密(AES-GCM 认证失败)。处理:写入 `env.example` 并标注为部署前置条件;解密失败时返回明确错误码而非 500。 4. **去重表保留期**:`automation_email_processed_messages` 无界增长,增加定时清理(保留 30 天,随调度器每日执行一次)。 @@ -157,21 +177,21 @@ WHERE trigger_type='邮件触发' | 表 | 变更 | 用途 | |---|---|---| | `automation_mailboxes` | 新增 `last_error` / `last_error_at` 列 | 邮箱连接状态与告警(模块 E.1) | -| `automation_tasks` | 幂等防御性清理:删除 system 邮箱任务(预期 0 行) | 系统邮箱下线(模块 A.1) | -| `automation_email_mailbox_cursors` | 无结构变更 | 删除/编辑邮箱时清理或重置(模块 D.3、D.4);清理 `mailbox_key='system'` 历史行(模块 A.2) | -| `automation_email_processed_messages` | 无结构变更 | 清理 `mailbox_key='system'` 历史行(模块 A.2);30 天保留期(模块 E.4) | -| `automation_email_rule_events` | 无结构变更 | 清理 `mailbox_key='system'` 历史行(模块 A.2) | +| `automation_tasks` | `trigger_config.mailboxKey`(字符串)→ `mailboxId`(整数);幂等防御性清理 system 邮箱任务(预期 0 行) | 键格式统一(模块 D.1);系统邮箱下线(模块 A.1) | +| `automation_email_mailbox_cursors` | `mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(主键列) | 键格式统一(模块 D.1) | +| `automation_email_processed_messages` | `mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(唯一键列) | 键格式统一(模块 D.1) | +| `automation_email_rule_events` | `mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(唯一键列) | 键格式统一(模块 D.1) | -**无新增表。** 由于每个邮箱归属唯一用户,游标表与去重表的 `created_by_user_id` 键保持不变,无需 schema 变更。 +**无新增表。** 三张邮件表的列类型变更需显式 `ALTER TABLE`(`CREATE TABLE IF NOT EXISTS` 不改动已存在的表),因表为空故变更安全。 ## 六、API 变更 | 端点 | 变更 | |---|---| -| `GET/POST /api/v1/automation-mailboxes` | 已存在,无变更 | +| `GET/POST /api/v1/automation-mailboxes` | 已存在;响应取消 `key: "mailbox:"` 字段,直接暴露整数 `id` | | `PUT /api/v1/automation-mailboxes/[id]` | **新增**(编辑,含真实连接验证、密码留空保留原值、游标重置) | | `DELETE /api/v1/automation-mailboxes/[id]` | 已存在;增加游标清理 | -| `POST/PUT /api/v1/automations` | 增加 mailboxKey 归属校验、拒绝 system、label 服务端派生 | +| `POST/PUT /api/v1/automations` | 增加 mailboxId 归属校验、拒绝 system、label 服务端派生 | | `GET /api/v1/automation-email/stats` | 无变更(前端在用) | | `POST /api/v1/automation-email/claim` | **删除**(死代码) | | `/api/system` 的 `smtp_config` | **不变**(发信,与本次无关) | @@ -190,9 +210,9 @@ WHERE trigger_type='邮件触发' 5. 不同邮箱的两个自动化 → **不产生**冲突提示 6. 编辑邮箱主机 → 确认游标重置、不重复处理历史邮件 7. 删除被引用的邮箱 → 409 拦截提示 -8. 尝试创建 `mailboxKey="system"` 的自动化 → 被拒绝并提示 +8. 尝试提交 `mailboxId` 指向他人邮箱或非法值 → 被拒绝并提示 9. 清理语句执行后,**其他触发类型的自动化与自定义邮箱自动化均不受影响**(这是删除语句的关键回归点) -10. 邮箱连接失败 → 通知中心出现提醒(10 分钟去抖)+ 状态标记为 error +10. 邮箱连接失败 → 通知中心出现一条提醒(邮箱恢复后自动消失)+ 状态标记为 error ## 八、外部依赖 @@ -210,16 +230,20 @@ WHERE trigger_type='邮件触发' - **取舍**:失去了"管理员配置一次、全员可用"的便利,非技术用户需要自己提供企业邮箱授权码。这是有意的选择。 - **存量处置**:**该功能从未被实际使用**(已确认),因此无存量数据、无兼容负担、无需发布说明或备份。仅保留一条防御性清理语句(模块 A)。 +**键格式统一为 mailboxId**(2026-09-11 确认):`mailbox:` 字符串编码的唯一存在理由是让 `"system"` 成为合法值;system 退场后该编码成为累赘。趁三张表为空一次改到位(模块 D.1)。**这是唯一一次无需数据迁移即可变更该结构的窗口。** + ## 十、建议实施顺序 -按"先修地基、再做界面、最后下线共享概念"排列,每步可独立验证: +按"先改数据模型、再修地基、然后做界面、最后下线共享概念"排列,每步可独立验证: -1. **模块 D.5 规则逻辑去重**(抽 `mail-rules.ts` + 单测)——纯重构,无行为变更,为后续修改提供单一事实来源 -2. **模块 D.1/D.2/D.7 服务端校验与死代码清理**——不依赖任何 UI,独立可测 -3. **模块 B 向导内联邮箱配置**(含 D.6 冲突检测隔离)——主路径,用户价值最高 -4. **模块 C 轻量邮箱管理入口**(含 PUT 端点、游标重置/清理、D.3/D.4) -5. **模块 E 健壮性改进**(状态列、通知派生、密钥前置条件、保留期) -6. **模块 A 系统邮箱下线**——放在最后,与模块 B/C 同一次发布(无存量数据,仅为保持"新路先通、再断旧路"的稳妥顺序) +1. **模块 D.1 键格式统一为 mailboxId**(含三张表列类型变更)+ **模块 A.2 遗留数据清理**——**必须最先做**:归属校验、分组键、游标逻辑都建立在 D.1 之上,后做会导致大量返工;而 A.2 的清理 SQL 必须排在 D.1 的列类型变更之前(`system` 无法转换为整数) +2. **模块 D.6 规则逻辑去重**(抽 `mail-rules.ts` + 单测)——纯重构,无行为变更,为后续修改提供单一事实来源 +3. **模块 D.2/D.3/D.8 服务端校验与死代码清理**——不依赖任何 UI,独立可测 +4. **模块 B 向导内联邮箱配置**(含 D.7 冲突检测隔离)——主路径,用户价值最高 +5. **模块 C 轻量邮箱管理入口**(含 PUT 端点、D.4 游标重置、D.5 游标清理) +6. **模块 E 健壮性改进**(状态列、通知派生、密钥前置条件、保留期) +7. **模块 A 系统邮箱下线(代码部分)**——放在最后,与模块 B/C 同一次发布:删除 `fetchSystemMailboxUnread`、`mailboxKey === "system"` 分支及各处 `|| "system"` 兜底。 + **注**:模块 A 在实施上是拆开的——A.2 的数据清理 SQL 属第 1 步,A 的代码下线属本步。这样既保证了迁移顺序正确,又保持了"新路先通、再断旧路"。 ## 十一、验收标准 @@ -228,5 +252,6 @@ WHERE trigger_type='邮件触发' - 多邮箱场景下冲突检测与优先级预测与实际调度行为一致 - 邮箱连接失败可被用户感知(通知 + 状态标记),而非仅存在于服务端日志 - 规则匹配逻辑单一来源,前后端行为不可能分叉 -- 系统邮箱下线后,代码中不再存在任何 system 分支或 `\|\| "system"` 兜底;存量 system 自动化被准确删除且未误伤其他任务 +- 系统邮箱下线后,代码中不再存在任何 system 分支或 `\|\| "system"` 兜底,也不再存在 `mailbox:` 字符串编解码 +- 防御性清理语句的 WHERE 范围经确认,不会误伤其他触发类型的自动化 - `pnpm test` 与 `pnpm check:ci` 通过 From dae265d8e903f8faaaf8375f8748bbfafe422a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 16:00:57 +0800 Subject: [PATCH 06/41] docs(automation): correct duplicate-trigger claim, record accepted residual risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正"彻底消除重复触发"的错误结论:mailboxId 唯一不等于物理邮箱唯一, 跨用户与同一用户均可重复注册同一物理邮箱,各成独立管道导致重复处理, 且优先级机制在此场景下失效。平台不阻止该行为(已确认), 作为有意接受的残余风险记录在第九节。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index 09a8df6..9eff74d 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -12,7 +12,9 @@ 本方案让用户在**创建自动化时直接配置监听邮箱**,并**下线"系统邮箱"这一共享概念**(已确认决策),同时修复上述缺陷。 -**关键收益**:系统邮箱退场后,每个邮箱都归属于唯一用户,分组键 `${userId}:${mailboxId}` 不再可能跨用户碰撞——彻底消除了"同一封邮件被多个用户各自处理"的重复触发风险,且**不再需要 ragent-service 配合开发**(原方案的最大外部依赖)。同时,由于功能从未使用、三张邮件表为空,可以趁此窗口把 `mailbox:` 字符串键统一为整数 `mailboxId`,拆掉为"system"服务的编码脚手架。 +**关键收益**:系统邮箱退场后,平台不再**内建**任何共享监听邮箱,分组键 `${userId}:${mailboxId}` 不再有来自内建共享的跨用户碰撞,且**不再需要 ragent-service 配合开发**(原方案的最大外部依赖)。同时,由于功能从未使用、三张邮件表为空,可以趁此窗口把 `mailbox:` 字符串键统一为整数 `mailboxId`,拆掉为"system"服务的编码脚手架。 + +> ⚠️ **注意**:退场消除的是"内建共享"这一**实例**,而非"同一物理邮箱被多条独立管道监听"这一**问题类**。用户仍可(且平台不阻止)手工把同一物理邮箱注册成多条记录,从而重新产生重复处理。详见第九节。 ## 二、范围 @@ -232,6 +234,23 @@ WHERE trigger_type='邮件触发' **键格式统一为 mailboxId**(2026-09-11 确认):`mailbox:` 字符串编码的唯一存在理由是让 `"system"` 成为合法值;system 退场后该编码成为累赘。趁三张表为空一次改到位(模块 D.1)。**这是唯一一次无需数据迁移即可变更该结构的窗口。** +**不阻止重复注册同一物理邮箱**(2026-09-11 确认):用户自行负责邮箱配置,平台不检测、不警告、不阻止任何用户(含同一用户)把同一物理邮箱注册成多条记录。这是一个**有意接受的残余风险**,其后果见下节。 + +### 已知限制:同一物理邮箱被重复注册 + +**物理身份 ≠ 记录唯一性。** 邮箱的物理身份是 `(imap_host, imap_port, username, folder)`,而 `automation_mailboxes` 的唯一约束是 `UNIQUE(created_by_user_id, email)`(`mailboxes.ts:37`)——按用户、按 `email` 标签约束,与物理身份无关。因此两条路径都能产生重复记录: + +- **跨用户**:A、B 各自注册共享邮箱 `sales@corp.com`,得到 `mailboxId=12` 与 `27`。 +- **同一用户**:`email` 与 `username` 是独立字段(`mailboxes.ts:84-102`,`username` 仅默认取 `email`),同一用户可用两个不同 `email` 标签注册同一物理邮箱,同样得到两个 `mailboxId`。 + +**后果**:每个 `mailboxId` 是一条独立管道——独立游标(cursor 主键含 `created_by_user_id`)、独立去重(`claimAutomationEmailMessage` 去重键含 `created_by_user_id`,`store.ts:1971`)。同一封邮件会被各自的 winner 分别处理,产生**重复动作**(重复回信 / 重复建单)。 + +**优先级机制在此场景下失效**:优先级竞争只在同一 `userId:mailboxId` 分组内进行(`scheduler.ts:555`)。两条独立管道互不知情,优先级不会阻止重复执行。 + +**用户侧的正确用法**:把多个自动化挂到**同一条邮箱记录**上(而非各建一条)。此时它们处于同一分组,优先级机制正常生效,同一封邮件只执行优先级最高的那一个。 + +**消除该风险的唯一办法**是引入平台级/租户级的物理邮箱去重与共享——即 system 邮箱的语义,已被明确排除。跨租户场景尤其不应检测(会泄露其他租户的配置信息)。 + ## 十、建议实施顺序 按"先改数据模型、再修地基、然后做界面、最后下线共享概念"排列,每步可独立验证: @@ -254,4 +273,5 @@ WHERE trigger_type='邮件触发' - 规则匹配逻辑单一来源,前后端行为不可能分叉 - 系统邮箱下线后,代码中不再存在任何 system 分支或 `\|\| "system"` 兜底,也不再存在 `mailbox:` 字符串编解码 - 防御性清理语句的 WHERE 范围经确认,不会误伤其他触发类型的自动化 +- 同一物理邮箱被重复注册时平台不阻止(有意接受),且该场景下的重复处理与优先级失效已在第九节明确记录 - `pnpm test` 与 `pnpm check:ci` 通过 From c3c0fd5396ad0a068372aca1c4b97ffdf46577fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 16:19:19 +0800 Subject: [PATCH 07/41] docs(automation): rebuild empty tables instead of writing migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三张邮件表为空,改为删除空表后按新结构重建,不做结构迁移。 消除 ALTER COLUMN TYPE 的硬失败路径、迁移顺序约束, 以及版本化迁移表与独立脚本的需要。非空时抛错中止。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index 9eff74d..b4ebed9 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -88,8 +88,7 @@ WHERE trigger_type='邮件触发' - **预期影响 0 行**。保留这条语句是防御性的:万一实际存在残留行,避免其在新代码下每 10 秒产生一次调度错误日志。天然幂等。 - 这是本方案唯一的破坏性语句,仍需在 review 时确认 WHERE 范围(防的是误伤其他任务,与存量数据无关)。 -2. **关联数据清理**:同样清理 `automation_email_mailbox_cursors`、`automation_email_processed_messages`、`automation_email_rule_events` 中 `mailbox_key='system'` 的行(预期同为 0 行)。 - **执行时机**:这三张表的清理**必须在模块 D.1 的列类型变更之前执行**(见 D.1 的顺序要求)——`system` 无法转换为整数,顺序颠倒会导致迁移失败。因此本模块的 SQL 虽然在功能上属于"下线",其数据清理语句需随 D.1 一起、排在其前面进入 `ensureAutomationTables()`。 +2. **关联数据清理**:三张邮件表(cursors / processed_messages / rule_events)**无需单独清理**——它们由模块 D.1 的空表重建一并替换(`system` 行随旧表一起消失)。 3. **运行历史**:`automation_runs` 表无外键级联,即便有历史运行记录也不受影响。 @@ -146,9 +145,33 @@ WHERE trigger_type='邮件触发' - 删除调度器的 `mailboxIdFromKey()` 正则解析;分组键由 `${userId}:${mailboxKey}` 改为 `${userId}:${mailboxId}` - 邮箱记录 API 的 `key: "mailbox:${id}"` 字段取消,直接暴露 `id` -**注**:表为空,列类型变更安全。`ensureAutomationTables()` 使用 `CREATE TABLE IF NOT EXISTS`,不会修改已存在的表结构,因此需要显式 `ALTER TABLE ... RENAME COLUMN` + `ALTER COLUMN ... TYPE INTEGER` 语句(同函数内已有 `ALTER TABLE ADD COLUMN IF NOT EXISTS` 的迁移先例)。 +**实现方式:重建空表,不做迁移。** -> ⚠️ **SQL 语句顺序要求**:模块 A.2 的遗留数据清理(删除 `mailbox_key='system'` 行)**必须排在本节的列类型变更之前**。`system` 是非数字字符串,若先执行 `ALTER COLUMN ... TYPE INTEGER`,PostgreSQL 会在这些行上转换失败并中止迁移。`ensureAutomationTables()` 内的语句顺序应为:建表 → 清理遗留 system 行 → 列重命名与类型变更。 +`CREATE TABLE IF NOT EXISTS` 对已存在的表不执行任何操作,因此仅修改 `CREATE` 语句**不会**改变现有表结构。而这三张表很可能已经存在——`ensureAutomationTables()` 不止在启动时执行,它在 31 处 store 函数开头都会调用;只要平台使用过**任何**自动化(包括与邮件无关的定时触发),这三张表就已被按旧结构建出。 + +由于表为空,最简做法是**删除空表后由 `CREATE TABLE IF NOT EXISTS` 按新结构重建**,而非编写迁移: + +```sql +-- 三张表各一段;仅当旧结构存在且表为空时删除 +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'automation_email_mailbox_cursors' + AND column_name = 'mailbox_key') + AND NOT EXISTS (SELECT 1 FROM automation_email_mailbox_cursors LIMIT 1) + THEN + DROP TABLE automation_email_mailbox_cursors; + END IF; +END $$; +``` + +- **自我失效**:执行过一次后列名已变,条件不再成立,此后每次启动均为 no-op。 +- **非破坏性**:只删除空表;**若检测到表非空则抛错中止**,而非静默跳过——静默跳过会留下新旧列并存的错配,比启动报错更难排查。 +- **并发安全**:`DROP TABLE` 取锁后会重新检查,不会误删。 + +本方案**不需要**:`ALTER COLUMN ... TYPE INTEGER`、`RENAME COLUMN`、expand-contract 展开、版本化迁移表、独立迁移脚本。上面的列变化是**新表的结构定义**,直接写在 `CREATE TABLE` 语句里即可。 + +**注**:`automation_tasks` 不能删(其中含定时触发等其他类型的真实数据),其 `trigger_config` 内遗留的 `mailboxKey` 由模块 A 的 `DELETE` 清理——那是数据清理,不涉及 DDL。 **D.2 mailboxId 归属校验**:`store.ts` 的 `createAutomation`(725-727)与 `updateAutomation`(875-877)邮件分支中,`mailboxId` 必须是正整数,并调用 `getAutomationMailboxForUser(userId, mailboxId)` 校验归属,不存在则抛错(不再有 `system` 特例)。 @@ -180,11 +203,11 @@ WHERE trigger_type='邮件触发' |---|---|---| | `automation_mailboxes` | 新增 `last_error` / `last_error_at` 列 | 邮箱连接状态与告警(模块 E.1) | | `automation_tasks` | `trigger_config.mailboxKey`(字符串)→ `mailboxId`(整数);幂等防御性清理 system 邮箱任务(预期 0 行) | 键格式统一(模块 D.1);系统邮箱下线(模块 A.1) | -| `automation_email_mailbox_cursors` | `mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(主键列) | 键格式统一(模块 D.1) | -| `automation_email_processed_messages` | `mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(唯一键列) | 键格式统一(模块 D.1) | -| `automation_email_rule_events` | `mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(唯一键列) | 键格式统一(模块 D.1) | +| `automation_email_mailbox_cursors` | 空表重建:`mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(主键列) | 键格式统一(模块 D.1) | +| `automation_email_processed_messages` | 空表重建:`mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(唯一键列) | 键格式统一(模块 D.1) | +| `automation_email_rule_events` | 空表重建:`mailbox_key VARCHAR(128)` → `mailbox_id INTEGER`(唯一键列) | 键格式统一(模块 D.1) | -**无新增表。** 三张邮件表的列类型变更需显式 `ALTER TABLE`(`CREATE TABLE IF NOT EXISTS` 不改动已存在的表),因表为空故变更安全。 +**无新增表,无迁移脚本。** 三张邮件表通过"空表删除 + `CREATE TABLE IF NOT EXISTS` 重建"完成结构变化(模块 D.1),非空则抛错中止。 ## 六、API 变更 @@ -255,14 +278,13 @@ WHERE trigger_type='邮件触发' 按"先改数据模型、再修地基、然后做界面、最后下线共享概念"排列,每步可独立验证: -1. **模块 D.1 键格式统一为 mailboxId**(含三张表列类型变更)+ **模块 A.2 遗留数据清理**——**必须最先做**:归属校验、分组键、游标逻辑都建立在 D.1 之上,后做会导致大量返工;而 A.2 的清理 SQL 必须排在 D.1 的列类型变更之前(`system` 无法转换为整数) +1. **模块 D.1 键格式统一为 mailboxId**(含三张空表重建)——**必须最先做**:归属校验、分组键、游标逻辑都建立在 D.1 之上,后做会导致大量返工 2. **模块 D.6 规则逻辑去重**(抽 `mail-rules.ts` + 单测)——纯重构,无行为变更,为后续修改提供单一事实来源 3. **模块 D.2/D.3/D.8 服务端校验与死代码清理**——不依赖任何 UI,独立可测 4. **模块 B 向导内联邮箱配置**(含 D.7 冲突检测隔离)——主路径,用户价值最高 5. **模块 C 轻量邮箱管理入口**(含 PUT 端点、D.4 游标重置、D.5 游标清理) 6. **模块 E 健壮性改进**(状态列、通知派生、密钥前置条件、保留期) -7. **模块 A 系统邮箱下线(代码部分)**——放在最后,与模块 B/C 同一次发布:删除 `fetchSystemMailboxUnread`、`mailboxKey === "system"` 分支及各处 `|| "system"` 兜底。 - **注**:模块 A 在实施上是拆开的——A.2 的数据清理 SQL 属第 1 步,A 的代码下线属本步。这样既保证了迁移顺序正确,又保持了"新路先通、再断旧路"。 +7. **模块 A 系统邮箱下线**——放在最后,与模块 B/C 同一次发布:删除 `fetchSystemMailboxUnread`、`mailboxKey === "system"` 分支及各处 `|| "system"` 兜底,并加入 `automation_tasks` 的防御性清理语句 ## 十一、验收标准 From d5f89b187932616e6f5f6215323747e15d972f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 16:22:35 +0800 Subject: [PATCH 08/41] docs(automation): reframe mailbox uniqueness note from risk to behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两人共用同一邮箱属协作失误而非正常用法,且向导的邮箱下拉 天然引导用户复用既有记录。将"已知限制"降级为行为说明, 并去掉与现有向导文案重复的提示。 Co-Authored-By: Claude Code --- ...6-09-11-automation-email-trigger-design.md | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index b4ebed9..29ec9dc 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -14,7 +14,7 @@ **关键收益**:系统邮箱退场后,平台不再**内建**任何共享监听邮箱,分组键 `${userId}:${mailboxId}` 不再有来自内建共享的跨用户碰撞,且**不再需要 ragent-service 配合开发**(原方案的最大外部依赖)。同时,由于功能从未使用、三张邮件表为空,可以趁此窗口把 `mailbox:` 字符串键统一为整数 `mailboxId`,拆掉为"system"服务的编码脚手架。 -> ⚠️ **注意**:退场消除的是"内建共享"这一**实例**,而非"同一物理邮箱被多条独立管道监听"这一**问题类**。用户仍可(且平台不阻止)手工把同一物理邮箱注册成多条记录,从而重新产生重复处理。详见第九节。 +> **边界说明**:退场消除的是"内建共享"这一**实例**。系统层面,同一物理邮箱若被注册成多条记录,仍会成为多条互不知情的管道(重复处理、优先级失效)。平台不阻止这种注册;正常用法下不会发生(见第九节「行为说明」)。 ## 二、范围 @@ -257,22 +257,15 @@ END $$; **键格式统一为 mailboxId**(2026-09-11 确认):`mailbox:` 字符串编码的唯一存在理由是让 `"system"` 成为合法值;system 退场后该编码成为累赘。趁三张表为空一次改到位(模块 D.1)。**这是唯一一次无需数据迁移即可变更该结构的窗口。** -**不阻止重复注册同一物理邮箱**(2026-09-11 确认):用户自行负责邮箱配置,平台不检测、不警告、不阻止任何用户(含同一用户)把同一物理邮箱注册成多条记录。这是一个**有意接受的残余风险**,其后果见下节。 +**不阻止重复注册同一物理邮箱**(2026-09-11 确认):用户自行负责邮箱配置,平台不检测、不警告、不阻止把同一物理邮箱注册成多条记录。 -### 已知限制:同一物理邮箱被重复注册 +### 行为说明:邮箱记录唯一性边界 -**物理身份 ≠ 记录唯一性。** 邮箱的物理身份是 `(imap_host, imap_port, username, folder)`,而 `automation_mailboxes` 的唯一约束是 `UNIQUE(created_by_user_id, email)`(`mailboxes.ts:37`)——按用户、按 `email` 标签约束,与物理身份无关。因此两条路径都能产生重复记录: +记录此项是为了说明系统属性,**不是风险警示**——两人各自在同一部署上给同一共享邮箱配置自动化属于协作失误,并非正常用法;且模块 B 的邮箱下拉会列出用户已有邮箱,用户自然会选择既有记录而非重录凭据,同用户重复这条路径也基本被交互堵住。 -- **跨用户**:A、B 各自注册共享邮箱 `sales@corp.com`,得到 `mailboxId=12` 与 `27`。 -- **同一用户**:`email` 与 `username` 是独立字段(`mailboxes.ts:84-102`,`username` 仅默认取 `email`),同一用户可用两个不同 `email` 标签注册同一物理邮箱,同样得到两个 `mailboxId`。 - -**后果**:每个 `mailboxId` 是一条独立管道——独立游标(cursor 主键含 `created_by_user_id`)、独立去重(`claimAutomationEmailMessage` 去重键含 `created_by_user_id`,`store.ts:1971`)。同一封邮件会被各自的 winner 分别处理,产生**重复动作**(重复回信 / 重复建单)。 - -**优先级机制在此场景下失效**:优先级竞争只在同一 `userId:mailboxId` 分组内进行(`scheduler.ts:555`)。两条独立管道互不知情,优先级不会阻止重复执行。 - -**用户侧的正确用法**:把多个自动化挂到**同一条邮箱记录**上(而非各建一条)。此时它们处于同一分组,优先级机制正常生效,同一封邮件只执行优先级最高的那一个。 - -**消除该风险的唯一办法**是引入平台级/租户级的物理邮箱去重与共享——即 system 邮箱的语义,已被明确排除。跨租户场景尤其不应检测(会泄露其他租户的配置信息)。 +- **物理身份 ≠ 记录唯一性**:邮箱的物理身份是 `(imap_host, imap_port, username, folder)`,而唯一约束是 `UNIQUE(created_by_user_id, email)`(`mailboxes.ts:37`),按用户、按 `email` 标签约束,与物理身份无关。 +- **每个 `mailboxId` 是一条独立管道**:独立游标(主键含 `created_by_user_id`)、独立去重(`claimAutomationEmailMessage` 去重键含 `created_by_user_id`,`store.ts:1971`)。 +- **优先级只在同一 `userId:mailboxId` 分组内生效**(`scheduler.ts:555`)。同一邮箱的多个自动化挂在**同一条记录**上时优先级正常生效;挂在两条记录上则互不知情、各自执行。向导现有文案已向用户说明该机制(`page.tsx:3122`),无需额外提示。 ## 十、建议实施顺序 @@ -295,5 +288,5 @@ END $$; - 规则匹配逻辑单一来源,前后端行为不可能分叉 - 系统邮箱下线后,代码中不再存在任何 system 分支或 `\|\| "system"` 兜底,也不再存在 `mailbox:` 字符串编解码 - 防御性清理语句的 WHERE 范围经确认,不会误伤其他触发类型的自动化 -- 同一物理邮箱被重复注册时平台不阻止(有意接受),且该场景下的重复处理与优先级失效已在第九节明确记录 +- 同一邮箱的多个自动化挂在同一条邮箱记录上时,优先级机制正常生效(同一封邮件只执行优先级最高的一条) - `pnpm test` 与 `pnpm check:ci` 通过 From 57e0c40ba1432633b88dfa32723e84056086d349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 16:34:54 +0800 Subject: [PATCH 09/41] docs(automation): add implementer handoff notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充新窗口接手所需但不属于任何模块的上下文: ensureAutomationTables 的失败重试行为、向导内联于 page.tsx、 ENABLE_CRON 前置条件、文件规模、i18n 与测试约定、行号时效性。 Co-Authored-By: Claude Code --- .../2026-09-11-automation-email-trigger-design.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md index 29ec9dc..f78629c 100644 --- a/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md +++ b/docs/superpowers/specs/2026-09-11-automation-email-trigger-design.md @@ -290,3 +290,15 @@ END $$; - 防御性清理语句的 WHERE 范围经确认,不会误伤其他触发类型的自动化 - 同一邮箱的多个自动化挂在同一条邮箱记录上时,优先级机制正常生效(同一封邮件只执行优先级最高的一条) - `pnpm test` 与 `pnpm check:ci` 通过 + +## 十二、交接说明(给实现者) + +以下事实不属于上文任何模块,但会影响实现,接手时请先读: + +1. **`ensureAutomationTables()` 失败会反复重试**:`initPromise` 在 catch 中被重置为 `null`(`store.ts:250-253`),因此其中任何语句失败后,**每一次 store 调用都会把整块 SQL 重跑一遍**,且错误会散落到自动化功能的各个入口,而不是一个干净的启动错误。模块 D.1 的空表重建语句就放在这个函数内——这正是选择"空表重建"而非"迁移"的根本原因:它必须不可能失败。 +2. **创建向导不是独立组件**:`app/automation/page.tsx` 有 4321 行、大量 `useState`,创建向导(step 1/2/3)与两个详情抽屉都内联在这一个组件里。模块 B 的改动全部落在这个文件内;只有模块 C 的邮箱管理抽屉才新建独立文件(`app/automation/components/MailboxManager.tsx`)。 +3. **手工验收需要 `ENABLE_CRON=true`**:邮件调度器在 `lib/cron/index.ts:5` 判断该环境变量后才初始化。未开启时邮件触发不会自动运行,验收只能走手动 `/run` 接口。 +4. **`lib/automation/store.ts` 有 2597 行**,本次改动点分散其中(创建/更新、游标、去重、通知派生、迁移语句),建议先 grep 定位再改。 +5. **新增文案遵守 AGENTS.md**:模块 C 的新组件用 next-intl + `messages/zh-CN` 与 `messages/en` 成对维护,提交前跑 `pnpm check:i18n`;自动化页存量 `tt()` 文案本次不动。 +6. **单元测试约定**:`test/*.test.ts`,`node --experimental-strip-types --test`,直接 import `../lib/...ts` 纯函数(参考 `test/chatSse.test.ts`)。 +7. **上文"现状事实"表中的 `file:line` 是 2026-09-11 的代码状态**,动手前建议先确认行号未漂移(grep 函数名比信行号更稳)。 From ae7f0ac06bdd707b4306e8d4e5f120994ff23eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 16:41:06 +0800 Subject: [PATCH 10/41] chore: ignore .superpowers scratch directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 55e3689..548b7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ docker/certs/ .pnpm-store/ .claude/ .data/ +.superpowers/ # OnlyOffice custom fonts live in docker/onlyoffice/fonts/ on each # deployment. Keep the directory (via .keep) but never commit the From e8ff869975b7184a1ab166f93b7e835dad3c8881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 16:56:49 +0800 Subject: [PATCH 11/41] feat(automation): unify email trigger key to integer mailboxId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 键格式统一(模块 D.1):mailbox: 字符串编码的唯一存在理由是让 "system" 成为合法值,改为整数 mailboxId 后该编码成为累赘。三张邮件表为空,趁此唯一 窗口直接改结构,不做迁移。 - trigger_config.mailboxKey(字符串)→ mailboxId(正整数) - 三张邮件表 mailbox_key VARCHAR(128) → mailbox_id INTEGER,写在新的 CREATE TABLE 定义里;ensureAutomationTables() 内按表加一段 DO 块: 旧列存在且表为空则 DROP 后重建,表非空则 RAISE 报错中止, 已为新结构时静默 no-op(自失效、幂等) - 删除调度器 mailboxIdFromKey(),分组键改为 ${userId}:${mailboxId} - 邮箱记录 API 取消 key: "mailbox:",直接暴露整数 id; deleteAutomationMailbox 的依赖检查同步改为按整数 mailboxId 匹配 - 新增 lib/automation/mailbox-id.ts 纯函数(normalizeMailboxId / requireMailboxId / mailboxGroupKey)与单测;读取邮箱标识的地方不再 回退 "system",缺失时抛 MAILBOX_ID_REQUIRED Co-Authored-By: Claude Code --- app/automation/page.tsx | 6 +- lib/automation/mailbox-id.ts | 26 ++++++ lib/automation/mailboxes.ts | 7 +- lib/automation/store.ts | 108 ++++++++++++++++++------- lib/cron/automation-scheduler.ts | 66 +++++---------- pages/api/v1/automation-email/claim.ts | 7 +- pages/api/v1/automations/[id].ts | 3 + pages/api/v1/automations/index.ts | 3 + test/automationMailboxId.test.ts | 70 ++++++++++++++++ 9 files changed, 211 insertions(+), 85 deletions(-) create mode 100644 lib/automation/mailbox-id.ts create mode 100644 test/automationMailboxId.test.ts diff --git a/app/automation/page.tsx b/app/automation/page.tsx index f93a87c..90ac7ba 100644 --- a/app/automation/page.tsx +++ b/app/automation/page.tsx @@ -90,7 +90,7 @@ interface Automation { scheduleDayOfMonth?: number; scheduleMissingDayPolicy?: "last_day" | "skip"; scheduleDate?: string; - mailboxKey?: string; + mailboxId?: number | null; mailboxLabel?: string; mailFolder?: string; mailRuleMode?: MailRuleMode; @@ -187,7 +187,7 @@ type EmailRoutingOutcome = "triggered" | "suppressed_by_priority" | "not_matched interface EmailRoutingEvent { id: number; - mailboxKey?: string; + mailboxId?: number; messageKey?: string; messageUid?: number; automationId: number; @@ -1346,7 +1346,7 @@ export default function AutomationPage() { trigger === "定时触发" && schedulePeriod === "仅一次" ? scheduleDate || undefined : undefined, - mailboxKey: trigger === "邮件触发" ? "system" : undefined, + // 邮件触发的监听邮箱为整数 mailboxId,由向导内的邮箱选择器下发;选择器接入前不下发该字段,服务端会明确拒绝。 mailboxLabel: trigger === "邮件触发" ? "系统邮箱" : undefined, mailFolder: trigger === "邮件触发" ? "INBOX" : undefined, mailRuleMode: trigger === "邮件触发" ? mailRuleMode : undefined, diff --git a/lib/automation/mailbox-id.ts b/lib/automation/mailbox-id.ts new file mode 100644 index 0000000..94bc48b --- /dev/null +++ b/lib/automation/mailbox-id.ts @@ -0,0 +1,26 @@ +/** + * 邮件触发监听邮箱的标识处理。 + * + * 历史格式 `mailbox:`(以及作为哨兵的 `"system"`)已废弃:mailboxId 一律是 + * 指向 `automation_mailboxes` 的正整数。任何读取该标识的地方都不再做隐式回退, + * 缺失或非法时抛出可识别的 MAILBOX_ID_REQUIRED,避免静默落到已下线的系统邮箱分支。 + */ + +export const MAILBOX_ID_REQUIRED = "MAILBOX_ID_REQUIRED"; + +/** 归一化邮箱标识:仅接受正整数,其余(含遗留的 `mailbox:`、`"system"`)返回 null。 */ +export function normalizeMailboxId(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; +} + +/** 读取邮箱标识:取不到合法值时抛错,而不是回退到 "system"。 */ +export function requireMailboxId(value: unknown): number { + const mailboxId = normalizeMailboxId(value); + if (mailboxId === null) throw new Error(MAILBOX_ID_REQUIRED); + return mailboxId; +} + +/** 调度分组键(决定优先级竞争与去重范围):同一用户同一监听邮箱为同一组。 */ +export function mailboxGroupKey(userId: number, mailboxId: number): string { + return `${userId}:${mailboxId}`; +} diff --git a/lib/automation/mailboxes.ts b/lib/automation/mailboxes.ts index 6e790a5..1296ad2 100644 --- a/lib/automation/mailboxes.ts +++ b/lib/automation/mailboxes.ts @@ -105,7 +105,6 @@ function normalizeInput(input: AutomationMailboxInput) { export function mailboxRowToApi(row: any) { return { id: Number(row.id), - key: `mailbox:${row.id}`, name: row.name, email: row.email, username: row.username, @@ -194,14 +193,14 @@ export function mailboxConnectionFromRow(row: any) { export async function deleteAutomationMailbox(userId: number, mailboxId: number) { await ensureAutomationMailboxTable(); - const mailboxKey = `mailbox:${mailboxId}`; + // 依赖检查按 trigger_config.mailboxId(整数)匹配:键格式统一后旧的 mailbox: 字符串已不存在。 const dependentResult = await pool.query( `SELECT id, name FROM automation_tasks WHERE created_by_user_id=$1 AND trigger_type='邮件触发' - AND trigger_config->>'mailboxKey'=$2 + AND trigger_config->>'mailboxId'=$2::text ORDER BY id`, - [userId, mailboxKey], + [userId, mailboxId], ); if (dependentResult.rows.length > 0) { diff --git a/lib/automation/store.ts b/lib/automation/store.ts index 2451315..2f88781 100644 --- a/lib/automation/store.ts +++ b/lib/automation/store.ts @@ -1,4 +1,5 @@ import pool from "@/lib/db"; +import { normalizeMailboxId, requireMailboxId } from "@/lib/automation/mailbox-id"; import { getUserTenantId } from "@/lib/tenantMapping"; export type AutomationTriggerType = "定时触发" | "邮件触发" | "Webhook / API" | "自动化完成触发"; @@ -30,6 +31,49 @@ export async function ensureAutomationTables() { initPromise = (async () => { await pool.query(` + -- 三张邮件表按“mailbox_id INTEGER”新结构重建(不做数据迁移)。 + -- 仅在旧列 mailbox_key 仍存在时介入:表为空则删除后由下面的 CREATE TABLE 重建; + -- 表非空则直接报错中止,避免留下新旧列并存的错配。旧列消失后条件不再成立, + -- 因此这段是自失效且幂等的——本函数会被反复调用,新结构下必须保持静默 no-op。 + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'automation_email_mailbox_cursors' + AND column_name = 'mailbox_key') THEN + IF NOT EXISTS (SELECT 1 FROM automation_email_mailbox_cursors LIMIT 1) THEN + DROP TABLE automation_email_mailbox_cursors; + ELSE + RAISE EXCEPTION 'automation_email_mailbox_cursors 仍含旧列 mailbox_key 且表中已有数据,请先清空该表后再启动'; + END IF; + END IF; + END $$; + + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'automation_email_processed_messages' + AND column_name = 'mailbox_key') THEN + IF NOT EXISTS (SELECT 1 FROM automation_email_processed_messages LIMIT 1) THEN + DROP TABLE automation_email_processed_messages; + ELSE + RAISE EXCEPTION 'automation_email_processed_messages 仍含旧列 mailbox_key 且表中已有数据,请先清空该表后再启动'; + END IF; + END IF; + END $$; + + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'automation_email_rule_events' + AND column_name = 'mailbox_key') THEN + IF NOT EXISTS (SELECT 1 FROM automation_email_rule_events LIMIT 1) THEN + DROP TABLE automation_email_rule_events; + ELSE + RAISE EXCEPTION 'automation_email_rule_events 仍含旧列 mailbox_key 且表中已有数据,请先清空该表后再启动'; + END IF; + END IF; + END $$; + CREATE TABLE IF NOT EXISTS automation_tasks ( id SERIAL PRIMARY KEY, tenant_id INTEGER, @@ -179,11 +223,11 @@ export async function ensureAutomationTables() { CREATE TABLE IF NOT EXISTS automation_email_processed_messages ( id SERIAL PRIMARY KEY, created_by_user_id INTEGER NOT NULL, - mailbox_key VARCHAR(128) NOT NULL, + mailbox_id INTEGER NOT NULL, message_key VARCHAR(500) NOT NULL, automation_id INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(created_by_user_id, mailbox_key, message_key) + UNIQUE(created_by_user_id, mailbox_id, message_key) ); CREATE INDEX IF NOT EXISTS idx_automation_email_processed_owner @@ -192,11 +236,11 @@ export async function ensureAutomationTables() { CREATE TABLE IF NOT EXISTS automation_email_mailbox_cursors ( created_by_user_id INTEGER NOT NULL, - mailbox_key VARCHAR(128) NOT NULL, + mailbox_id INTEGER NOT NULL, last_uid BIGINT NOT NULL DEFAULT 0, initialized BOOLEAN NOT NULL DEFAULT FALSE, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (created_by_user_id, mailbox_key) + PRIMARY KEY (created_by_user_id, mailbox_id) ); CREATE INDEX IF NOT EXISTS idx_automation_email_cursor_updated @@ -205,7 +249,7 @@ export async function ensureAutomationTables() { CREATE TABLE IF NOT EXISTS automation_email_rule_events ( id SERIAL PRIMARY KEY, created_by_user_id INTEGER NOT NULL, - mailbox_key VARCHAR(128) NOT NULL, + mailbox_id INTEGER NOT NULL, message_key VARCHAR(500) NOT NULL, message_uid BIGINT, automation_id INTEGER NOT NULL, @@ -218,14 +262,14 @@ export async function ensureAutomationTables() { subject TEXT, message_date TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(created_by_user_id, mailbox_key, message_key, automation_id) + UNIQUE(created_by_user_id, mailbox_id, message_key, automation_id) ); CREATE INDEX IF NOT EXISTS idx_automation_email_rule_events_automation ON automation_email_rule_events(created_by_user_id, automation_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_automation_email_rule_events_mailbox - ON automation_email_rule_events(created_by_user_id, mailbox_key, created_at DESC); + ON automation_email_rule_events(created_by_user_id, mailbox_id, created_at DESC); CREATE TABLE IF NOT EXISTS automation_notification_preferences ( user_id INTEGER PRIMARY KEY, @@ -722,7 +766,7 @@ export async function createAutomation(userId: number, input: any) { nextRunAt = status === "running" ? computeNextRunAt(schedule) : null; } else if (triggerType === "邮件触发") { Object.assign(triggerConfig, { - mailboxKey: String(input.mailboxKey ?? input.triggerConfig?.mailboxKey ?? "system"), + mailboxId: requireMailboxId(input.mailboxId ?? input.triggerConfig?.mailboxId), mailboxLabel: String(input.mailboxLabel ?? input.triggerConfig?.mailboxLabel ?? "系统邮箱"), folder: String(input.mailFolder ?? input.triggerConfig?.folder ?? "INBOX"), ruleMode: (input.mailRuleMode ?? input.triggerConfig?.ruleMode) === "any" ? "any" : "all", @@ -872,7 +916,7 @@ export async function updateAutomation(userId: number, id: number, input: any) { triggerConfig = schedule; } else if (triggerType === "邮件触发") { triggerConfig = { - mailboxKey: String(input.mailboxKey ?? input.triggerConfig?.mailboxKey ?? triggerConfig.mailboxKey ?? "system"), + mailboxId: requireMailboxId(input.mailboxId ?? input.triggerConfig?.mailboxId ?? triggerConfig.mailboxId), mailboxLabel: String(input.mailboxLabel ?? input.triggerConfig?.mailboxLabel ?? triggerConfig.mailboxLabel ?? "系统邮箱"), folder: String(input.mailFolder ?? input.triggerConfig?.folder ?? triggerConfig.folder ?? "INBOX"), ruleMode: (input.mailRuleMode ?? input.triggerConfig?.ruleMode ?? triggerConfig.ruleMode) === "any" ? "any" : "all", @@ -1956,23 +2000,23 @@ export async function rejectRunReview( export async function claimAutomationEmailMessage( userId: number, - mailboxKey: string, + mailboxId: number, messageKey: string, automationId: number ) { await ensureAutomationTables(); - const safeMailboxKey = String(mailboxKey || "system").trim() || "system"; + const safeMailboxId = requireMailboxId(mailboxId); const safeMessageKey = String(messageKey || "").trim(); if (!safeMessageKey) throw new Error("EMAIL_MESSAGE_KEY_REQUIRED"); const result = await pool.query( `INSERT INTO automation_email_processed_messages ( - created_by_user_id, mailbox_key, message_key, automation_id + created_by_user_id, mailbox_id, message_key, automation_id ) VALUES ($1,$2,$3,$4) - ON CONFLICT (created_by_user_id, mailbox_key, message_key) DO NOTHING + ON CONFLICT (created_by_user_id, mailbox_id, message_key) DO NOTHING RETURNING id`, - [userId, safeMailboxKey, safeMessageKey.slice(0, 500), automationId] + [userId, safeMailboxId, safeMessageKey.slice(0, 500), automationId] ); return result.rows.length > 0; @@ -1986,7 +2030,7 @@ export type AutomationEmailRuleOutcome = export type AutomationEmailRuleEvaluationInput = { userId: number; - mailboxKey: string; + mailboxId: number; messageKey: string; messageUid?: number; automationId: number; @@ -2008,7 +2052,7 @@ export async function recordAutomationEmailRuleEvaluations( .filter((item) => Number.isInteger(Number(item.userId)) && Number.isInteger(Number(item.automationId))) .map((item) => ({ ...item, - mailboxKey: String(item.mailboxKey || "system").trim() || "system", + mailboxId: requireMailboxId(item.mailboxId), messageKey: String(item.messageKey || "").trim().slice(0, 500), })) .filter((item) => item.messageKey); @@ -2021,7 +2065,7 @@ export async function recordAutomationEmailRuleEvaluations( const start = params.length; params.push( item.userId, - item.mailboxKey, + item.mailboxId, item.messageKey, Number.isFinite(Number(item.messageUid)) ? Number(item.messageUid) : null, item.automationId, @@ -2040,11 +2084,11 @@ export async function recordAutomationEmailRuleEvaluations( await pool.query( `INSERT INTO automation_email_rule_events ( - created_by_user_id, mailbox_key, message_key, message_uid, + created_by_user_id, mailbox_id, message_key, message_uid, automation_id, outcome, winner_automation_id, matched_rule, priority, from_address, to_address, subject, message_date, created_at ) VALUES ${values.join(",")} - ON CONFLICT (created_by_user_id, mailbox_key, message_key, automation_id) DO NOTHING`, + ON CONFLICT (created_by_user_id, mailbox_id, message_key, automation_id) DO NOTHING`, params, ); } @@ -2067,7 +2111,7 @@ export async function getAutomationEmailRoutingStats(userId: number, automationI const recentResult = await pool.query( `SELECT - id, mailbox_key, message_key, message_uid, automation_id, outcome, + id, mailbox_id, message_key, message_uid, automation_id, outcome, winner_automation_id, matched_rule, priority, from_address, to_address, subject, message_date, created_at FROM automation_email_rule_events @@ -2087,7 +2131,7 @@ export async function getAutomationEmailRoutingStats(userId: number, automationI duplicate: Number(row.duplicate || 0), recent: recentResult.rows.map((item: any) => ({ id: Number(item.id), - mailboxKey: item.mailbox_key, + mailboxId: Number(item.mailbox_id), messageKey: item.message_key, messageUid: item.message_uid == null ? undefined : Number(item.message_uid), automationId: Number(item.automation_id), @@ -2466,7 +2510,9 @@ export function automationRowToApi(row: any) { scheduleMissingDayPolicy: config.missingDayPolicy === "skip" ? "skip" : "last_day", scheduleDate, - mailboxKey: config.mailboxKey || "system", + // 展示层不再回退到 "system":遗留数据没有 mailboxId 时原样返回 null, + // 真正依赖该标识的路径(调度器、游标、去重、创建/更新)会显式报错。 + mailboxId: normalizeMailboxId(config.mailboxId), mailboxLabel: config.mailboxLabel || "系统邮箱", mailFolder: config.folder || "INBOX", mailRuleMode: (config.ruleMode === "any" ? "any" : "all") as EmailRuleMode, @@ -2550,16 +2596,16 @@ export async function listActiveEmailAutomationsForScheduler() { export async function getAutomationEmailMailboxCursor( userId: number, - mailboxKey: string + mailboxId: number ) { await ensureAutomationTables(); - const safeMailboxKey = String(mailboxKey || "system").trim() || "system"; + const safeMailboxId = requireMailboxId(mailboxId); const result = await pool.query( `SELECT last_uid, initialized, updated_at FROM automation_email_mailbox_cursors - WHERE created_by_user_id=$1 AND mailbox_key=$2 + WHERE created_by_user_id=$1 AND mailbox_id=$2 LIMIT 1`, - [userId, safeMailboxKey] + [userId, safeMailboxId] ); const row = result.rows[0]; @@ -2572,24 +2618,24 @@ export async function getAutomationEmailMailboxCursor( export async function saveAutomationEmailMailboxCursor( userId: number, - mailboxKey: string, + mailboxId: number, lastUid: number, initialized = true ) { await ensureAutomationTables(); - const safeMailboxKey = String(mailboxKey || "system").trim() || "system"; + const safeMailboxId = requireMailboxId(mailboxId); const safeUid = Number.isFinite(Number(lastUid)) ? Math.max(0, Math.floor(Number(lastUid))) : 0; const result = await pool.query( `INSERT INTO automation_email_mailbox_cursors ( - created_by_user_id, mailbox_key, last_uid, initialized, updated_at + created_by_user_id, mailbox_id, last_uid, initialized, updated_at ) VALUES ($1,$2,$3,$4,NOW()) - ON CONFLICT (created_by_user_id, mailbox_key) DO UPDATE SET + ON CONFLICT (created_by_user_id, mailbox_id) DO UPDATE SET last_uid=GREATEST(automation_email_mailbox_cursors.last_uid, EXCLUDED.last_uid), initialized=automation_email_mailbox_cursors.initialized OR EXCLUDED.initialized, updated_at=NOW() RETURNING *`, - [userId, safeMailboxKey, safeUid, initialized] + [userId, safeMailboxId, safeUid, initialized] ); return result.rows[0] || null; diff --git a/lib/cron/automation-scheduler.ts b/lib/cron/automation-scheduler.ts index 5631566..4036a94 100644 --- a/lib/cron/automation-scheduler.ts +++ b/lib/cron/automation-scheduler.ts @@ -21,6 +21,11 @@ import { getAutomationMailboxForUser, mailboxConnectionFromRow, } from "@/lib/automation/mailboxes"; +import { + mailboxGroupKey, + normalizeMailboxId, + requireMailboxId, +} from "@/lib/automation/mailbox-id"; import { fetchMailboxUnread } from "@/lib/automation/mailbox-client"; declare global { @@ -219,11 +224,6 @@ function serverAuthorization(userId: number) { return `Bearer ${token}`; } -function mailboxIdFromKey(mailboxKey: string) { - const match = String(mailboxKey || "").match(/^mailbox:(\d+)$/); - return match ? Number(match[1]) : null; -} - function extractSenderDomain(value?: string) { const match = String(value || "").match(/@([^>\s,;]+)/); return match?.[1]?.toLowerCase() || ""; @@ -296,40 +296,13 @@ function mailRulesSummary(task: any) { return `${prefix}:${rules.map(mailRuleText).join(";")}`; } -async function fetchSystemMailboxUnread(userId: number, afterUid?: number) { - const backendUrl = requiredEnv("EXTERNAL_API_BASE_URL").replace(/\/+$/, ""); - const params = new URLSearchParams(); - if (Number.isInteger(afterUid)) params.set("after_uid", String(afterUid)); - const response = await fetch( - `${backendUrl}/api/v1/email/unread${params.toString() ? `?${params.toString()}` : ""}`, - { headers: { Authorization: serverAuthorization(userId) } } - ); - - const raw = await response.text(); - let data: any = raw; - try { data = raw ? JSON.parse(raw) : null; } catch { /* keep raw */ } - - if (!response.ok) { - const detail = typeof data === "object" && data?.detail ? data.detail : String(data || `HTTP ${response.status}`); - throw new Error(detail); - } - return data ?? { success: true, latest_uid: 0, messages: [] }; -} - async function fetchConfiguredMailboxUnread( userId: number, - mailboxKey: string, + mailboxId: number, afterUid?: number ) { - if (mailboxKey === "system") { - return fetchSystemMailboxUnread(userId, afterUid); - } - - const mailboxId = mailboxIdFromKey(mailboxKey); - if (!mailboxId) throw new Error(`监听邮箱标识无效:${mailboxKey}`); - const mailbox = await getAutomationMailboxForUser(userId, mailboxId); - if (!mailbox) throw new Error(`监听邮箱不存在:${mailboxKey}`); + if (!mailbox) throw new Error(`监听邮箱不存在:${mailboxId}`); return fetchMailboxUnread({ authorization: serverAuthorization(userId), @@ -373,7 +346,7 @@ async function executeEmailAutomation(task: any, message: InboxMessage) { firedAt: new Date().toISOString(), uid: Number(message.uid), messageId: message.message_id || undefined, - mailboxKey: config.mailboxKey || "system", + mailboxId: requireMailboxId(config.mailboxId), mailbox: config.mailboxLabel || "系统邮箱", folder: config.folder || "INBOX", matchedRule: mailRulesSummary(task), @@ -453,12 +426,13 @@ async function processEmailMailboxGroup(tasks: any[]) { const userId = Number(tasks[0].created_by_user_id); const config = tasks[0].trigger_config || {}; - const mailboxKey = String(config.mailboxKey || "system").trim() || "system"; - const cursor = await getAutomationEmailMailboxCursor(userId, mailboxKey); + // 遗留数据缺少整数 mailboxId 时直接抛错(不再回退 system),错误由 scanEmailAutomations 按分组记录。 + const mailboxId = requireMailboxId(config.mailboxId); + const cursor = await getAutomationEmailMailboxCursor(userId, mailboxId); const data = await fetchConfiguredMailboxUnread( userId, - mailboxKey, + mailboxId, cursor.initialized ? cursor.lastUid : undefined ); @@ -467,7 +441,7 @@ async function processEmailMailboxGroup(tasks: any[]) { // 第一次建立服务端基线,不处理历史邮件。 if (!cursor.initialized) { - await saveAutomationEmailMailboxCursor(userId, mailboxKey, latestUid, true); + await saveAutomationEmailMailboxCursor(userId, mailboxId, latestUid, true); return; } @@ -494,7 +468,7 @@ async function processEmailMailboxGroup(tasks: any[]) { if (winner) { claimed = await claimAutomationEmailMessage( userId, - mailboxKey, + mailboxId, messageKey, Number(winner.id) ); @@ -515,7 +489,7 @@ async function processEmailMailboxGroup(tasks: any[]) { return { userId, - mailboxKey, + mailboxId, messageKey, messageUid: uid, automationId: taskId, @@ -537,7 +511,7 @@ async function processEmailMailboxGroup(tasks: any[]) { } // 无论是否命中规则都推进游标;规则调整不会回溯历史邮件。 - await saveAutomationEmailMailboxCursor(userId, mailboxKey, uid, true); + await saveAutomationEmailMailboxCursor(userId, mailboxId, uid, true); } } @@ -551,8 +525,10 @@ export async function scanEmailAutomations() { for (const task of tasks) { const userId = Number(task.created_by_user_id); - const mailboxKey = String(task.trigger_config?.mailboxKey || "system").trim() || "system"; - const key = `${userId}:${mailboxKey}`; + // 分组键:同一用户同一监听邮箱为一组(决定优先级竞争与去重范围)。 + // 遗留数据没有整数 mailboxId,单独归组后由 processEmailMailboxGroup 抛错。 + const mailboxId = normalizeMailboxId(task.trigger_config?.mailboxId); + const key = mailboxId === null ? `${userId}:MAILBOX_ID_REQUIRED` : mailboxGroupKey(userId, mailboxId); const current = groups.get(key) || []; current.push(task); groups.set(key, current); @@ -564,7 +540,7 @@ export async function scanEmailAutomations() { } catch (error) { const first = groupTasks[0]; console.error( - `[Automation Email] mailbox scan failed: user=${first?.created_by_user_id} mailbox=${first?.trigger_config?.mailboxKey || "system"}`, + `[Automation Email] mailbox scan failed: user=${first?.created_by_user_id} automation=${first?.id} mailboxId=${first?.trigger_config?.mailboxId ?? "(缺失)"}`, error ); } diff --git a/pages/api/v1/automation-email/claim.ts b/pages/api/v1/automation-email/claim.ts index a448a65..8812e94 100644 --- a/pages/api/v1/automation-email/claim.ts +++ b/pages/api/v1/automation-email/claim.ts @@ -12,12 +12,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const automationId = Number(req.body?.automationId); - const mailboxKey = String(req.body?.mailboxKey || "system").trim() || "system"; + const mailboxId = Number(req.body?.mailboxId); const messageKey = String(req.body?.messageKey || "").trim(); if (!Number.isInteger(automationId) || automationId <= 0) { return res.status(400).json({ detail: "自动化 ID 无效" }); } + if (!Number.isInteger(mailboxId) || mailboxId <= 0) { + return res.status(400).json({ detail: "监听邮箱 ID 无效" }); + } if (!messageKey) { return res.status(400).json({ detail: "邮件唯一标识不能为空" }); } @@ -30,7 +33,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const claimed = await claimAutomationEmailMessage( userId, - mailboxKey, + mailboxId, messageKey, automationId, ); diff --git a/pages/api/v1/automations/[id].ts b/pages/api/v1/automations/[id].ts index 503e82c..c004dc9 100644 --- a/pages/api/v1/automations/[id].ts +++ b/pages/api/v1/automations/[id].ts @@ -74,6 +74,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (code === "APP_NOT_FOUND") return res.status(404).json({ detail: "数字员工不存在" }); if (code === "NAME_REQUIRED") return res.status(400).json({ detail: "请填写自动化名称" }); if (code === "TASK_REQUIRED") return res.status(400).json({ detail: "请填写任务说明" }); + if (code === "MAILBOX_ID_REQUIRED") { + return res.status(400).json({ detail: "请为邮件触发选择监听邮箱" }); + } const scheduleMessage = scheduleErrorResponse(code); if (scheduleMessage) return res.status(400).json({ detail: scheduleMessage }); diff --git a/pages/api/v1/automations/index.ts b/pages/api/v1/automations/index.ts index 7488f12..f0a76e2 100644 --- a/pages/api/v1/automations/index.ts +++ b/pages/api/v1/automations/index.ts @@ -45,6 +45,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (code === "APP_NOT_FOUND") return res.status(404).json({ detail: "数字员工不存在" }); if (code === "NAME_REQUIRED") return res.status(400).json({ detail: "请填写自动化名称" }); if (code === "TASK_REQUIRED") return res.status(400).json({ detail: "请填写任务说明" }); + if (code === "MAILBOX_ID_REQUIRED") { + return res.status(400).json({ detail: "请为邮件触发选择监听邮箱" }); + } const scheduleMessage = scheduleErrorResponse(code); if (scheduleMessage) return res.status(400).json({ detail: scheduleMessage }); diff --git a/test/automationMailboxId.test.ts b/test/automationMailboxId.test.ts new file mode 100644 index 0000000..589f3b4 --- /dev/null +++ b/test/automationMailboxId.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + MAILBOX_ID_REQUIRED, + mailboxGroupKey, + normalizeMailboxId, + requireMailboxId, +} from "../lib/automation/mailbox-id.ts"; + +test("normalizeMailboxId: 正整数原样返回", () => { + assert.equal(normalizeMailboxId(12), 12); + assert.equal(normalizeMailboxId(1), 1); + assert.equal(normalizeMailboxId(999999), 999999); +}); + +test("normalizeMailboxId: 遗留的 mailbox: 字符串与 system 一律视为无效", () => { + assert.equal(normalizeMailboxId("mailbox:12"), null); + assert.equal(normalizeMailboxId("system"), null); + assert.equal(normalizeMailboxId("12"), null); +}); + +test("normalizeMailboxId: 非正整数与其他类型一律视为无效", () => { + const invalid = [ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + "", + null, + undefined, + true, + {}, + [], + ]; + for (const value of invalid) { + assert.equal(normalizeMailboxId(value), null, `${String(value)} 应判定为无效`); + } +}); + +test("requireMailboxId: 合法取值通过", () => { + assert.equal(requireMailboxId(7), 7); +}); + +test("requireMailboxId: 缺失 mailboxId 时抛出可识别错误,而非回退 system", () => { + for (const value of [undefined, null, "system", "mailbox:7", 0, "7"]) { + assert.throws( + () => requireMailboxId(value), + (error: Error) => error.message === MAILBOX_ID_REQUIRED, + `${String(value)} 应抛错` + ); + } +}); + +test("requireMailboxId: 错误信息指明缺失的 mailboxId 字段", () => { + assert.throws(() => requireMailboxId(undefined), /MAILBOX_ID_REQUIRED/); +}); + +test("mailboxGroupKey: 分组键为 userId:mailboxId", () => { + assert.equal(mailboxGroupKey(3, 12), "3:12"); +}); + +test("mailboxGroupKey: 不再出现 mailbox: 字符串编码", () => { + assert.equal(mailboxGroupKey(3, 12).includes("mailbox:"), false); +}); + +test("mailboxGroupKey: 不同用户或不同邮箱不会落进同一分组", () => { + const keys = new Set([mailboxGroupKey(1, 2), mailboxGroupKey(1, 3), mailboxGroupKey(2, 2)]); + assert.equal(keys.size, 3); +}); From 328297ab1f2d81b66b1e9e9825b9343df20e7491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 17:19:27 +0800 Subject: [PATCH 12/41] refactor(automation): extract mail rule logic into lib/automation/mail-rules.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规则匹配逻辑去重(模块 D.6):三份独立维护的副本合并为单一实现, 让向导里预览的命中结果与调度器实际执行的结果不可能分叉。 - 新增纯函数模块 lib/automation/mail-rules.ts(零 import,同构,同时被 服务端调度器与 "use client" 的自动化页引用,不引入任何服务端依赖) - 调度器删除 mailRuleSource / doesMailRuleMatch / mailAutomationMatches / mailRuleText / mailRulesSummary 与两个取值辅助函数,改为经 mailRuleSetFromTask() 适配后调用共享实现 - 前端删除同一套逻辑(含测试器与冲突检测用到的 normalizedMailRule / mailRuleSetsEqual / mailConflictLevel),改为 automationMailRuleSet() 适配 - store.ts 删除第三份副本 emailRuleSummary(spec 只提到两份),精简摘要 格式原样迁移为 mailRulesBriefSummary:它与共享摘要的输出格式不同 (只显示首条 + 条数,不区分存在性判断),统一会改变接口返回文案, 属行为变更,留待控制器裁决 - 规范化类型与字段、操作符清单集中到模块,用 as const + 字面量联合, 不使用 enum / namespace(--experimental-strip-types 无法转换) - 新增 test/mail-rules.test.ts:字段 × 操作符矩阵、AND/OR 模式、 空值与大小写边界、附件扩展名提取 --- app/automation/page.tsx | 185 ++----------- lib/automation/mail-rules.ts | 222 ++++++++++++++++ lib/automation/store.ts | 13 +- lib/cron/automation-scheduler.ts | 90 +------ test/mail-rules.test.ts | 442 +++++++++++++++++++++++++++++++ 5 files changed, 705 insertions(+), 247 deletions(-) create mode 100644 lib/automation/mail-rules.ts create mode 100644 test/mail-rules.test.ts diff --git a/app/automation/page.tsx b/app/automation/page.tsx index 90ac7ba..ac1b093 100644 --- a/app/automation/page.tsx +++ b/app/automation/page.tsx @@ -18,6 +18,22 @@ import { Trash2, X, } from "lucide-react"; +import { + MAIL_RULE_FIELDS, + MAIL_RULE_OPERATORS, + doesMailRuleMatch, + doesMailRuleSetMatch, + mailConflictLevel, + mailRuleSource, + mailRulesSummary, + mailRuleText, + type MailRuleField, + type MailRuleMessage, + type MailRuleMode, + type MailRuleOperator, + type MailRuleSet, + type MailTriggerRule, +} from "@/lib/automation/mail-rules"; type AutomationStatus = "running" | "paused" | "error"; type RunStatus = @@ -31,32 +47,6 @@ type RunStatus = | "timed_out"; type TriggerType = "定时触发" | "邮件触发" | "Webhook / API" | "自动化完成触发"; type StrategyType = "仅生成结果" | "需要确认后执行" | "自动执行"; -type MailRuleMode = "all" | "any"; -type MailRuleField = - | "发件人" - | "发件人域名" - | "收件人" - | "邮件主题" - | "邮件正文" - | "是否包含附件" - | "附件名称" - | "附件类型"; -type MailRuleOperator = "等于" | "包含" | "不包含" | "开头是" | "结尾是" | "是否存在"; -interface MailTriggerRule { - id: string; - field: MailRuleField; - operator: MailRuleOperator; - value: string; -} - -type MailRuleTestMessage = { - from: string; - to: string; - subject: string; - body: string; - attachments: string[]; -}; - interface AppOption { id: number; name: string; @@ -226,18 +216,6 @@ const CHAIN_PROCESSED_STORAGE_KEY = "ragent_chain_processed_runs_v1"; const WEBHOOK_POLL_INTERVAL_MS = 5000; -const MAIL_RULE_FIELDS: MailRuleField[] = [ - "发件人", - "发件人域名", - "收件人", - "邮件主题", - "邮件正文", - "是否包含附件", - "附件名称", - "附件类型", -]; -const MAIL_RULE_OPERATORS: MailRuleOperator[] = ["等于", "包含", "不包含", "开头是", "结尾是", "是否存在"]; - function newMailRule(): MailTriggerRule { return { id: `mail-rule-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -247,47 +225,9 @@ function newMailRule(): MailTriggerRule { }; } -function mailRuleText(rule: MailTriggerRule) { - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - return `${rule.field}${rule.value || "是"}`; - } - return `${rule.field}${rule.operator}“${rule.value}”`; -} - -function mailRulesSummary(item: Automation) { - const rules = Array.isArray(item.mailRules) ? item.mailRules : []; - if (rules.length === 0) return "收到新邮件即触发"; - const prefix = item.mailRuleMode === "any" ? "任一" : "全部"; - return `${prefix}:${rules.map(mailRuleText).join(";")}`; -} - -function normalizedMailRule(rule: MailTriggerRule) { - return `${rule.field}|${rule.operator}|${String(rule.value || "").trim().toLowerCase()}`; -} - -function mailRuleSetsEqual( - leftRules: MailTriggerRule[], - leftMode: MailRuleMode, - rightRules: MailTriggerRule[], - rightMode: MailRuleMode, -) { - if (leftMode !== rightMode || leftRules.length !== rightRules.length) return false; - const left = leftRules.map(normalizedMailRule).sort(); - const right = rightRules.map(normalizedMailRule).sort(); - return left.every((value, index) => value === right[index]); -} - -function mailConflictLevel( - currentRules: MailTriggerRule[], - currentMode: MailRuleMode, - other: Automation, -): "high" | "possible" { - const otherRules = Array.isArray(other.mailRules) ? other.mailRules : []; - const otherMode = other.mailRuleMode === "any" ? "any" : "all"; - - if (currentRules.length === 0 || otherRules.length === 0) return "high"; - if (mailRuleSetsEqual(currentRules, currentMode, otherRules, otherMode)) return "high"; - return "possible"; +// Automation(接口返回)到规范化规则集的适配;规则逻辑本身在 mail-rules.ts。 +function automationMailRuleSet(item: Automation): MailRuleSet { + return { rules: item.mailRules, mode: item.mailRuleMode }; } function mailFolderDisplay(value: unknown) { @@ -304,65 +244,6 @@ function emailContextText(value: unknown, fallback = "-") { return text || fallback; } -function extractMailSenderDomain(value?: string) { - const match = String(value || "").match(/@([^>\s,;]+)/); - return match?.[1]?.toLowerCase() || ""; -} - -function mailAttachmentExtensions(names?: string[]) { - return (Array.isArray(names) ? names : []) - .map((item) => { - const match = String(item).toLowerCase().match(/(\.[a-z0-9]+)$/i); - return match?.[1] || ""; - }) - .filter(Boolean) - .join(" "); -} - -function mailRuleTestSource(rule: MailTriggerRule, message: MailRuleTestMessage) { - const attachments = Array.isArray(message.attachments) ? message.attachments : []; - switch (rule.field) { - case "发件人": return String(message.from || ""); - case "发件人域名": return extractMailSenderDomain(message.from); - case "收件人": return String(message.to || ""); - case "邮件主题": return String(message.subject || ""); - case "邮件正文": return String(message.body || ""); - case "是否包含附件": return attachments.length > 0 ? "是" : "否"; - case "附件名称": return attachments.join(" "); - case "附件类型": return mailAttachmentExtensions(attachments); - default: return ""; - } -} - -function doesMailRuleTestMatch(rule: MailTriggerRule, message: MailRuleTestMessage) { - const source = mailRuleTestSource(rule, message).toLowerCase(); - const wanted = String(rule.value || "").trim().toLowerCase(); - - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - const exists = rule.field === "是否包含附件" ? source === "是" : source.trim().length > 0; - const wantExists = !["否", "false", "0", "no"].includes(wanted || "是"); - return exists === wantExists; - } - - if (!wanted) return false; - if (rule.operator === "等于") return source.trim() === wanted; - if (rule.operator === "包含") return source.includes(wanted); - if (rule.operator === "不包含") return !source.includes(wanted); - if (rule.operator === "开头是") return source.startsWith(wanted); - if (rule.operator === "结尾是") return source.endsWith(wanted); - return false; -} - -function doMailRulesTestMatch( - rules: MailTriggerRule[], - mode: MailRuleMode, - message: MailRuleTestMessage, -) { - if (rules.length === 0) return true; - const results = rules.map((rule) => doesMailRuleTestMatch(rule, message)); - return mode === "any" ? results.some(Boolean) : results.every(Boolean); -} - function splitMailTestAttachments(value: string) { return String(value || "") .split(/[\n,,]/) @@ -670,7 +551,7 @@ export default function AutomationPage() { }) .map((item) => ({ item, - level: mailConflictLevel(mailRules, mailRuleMode, item), + level: mailConflictLevel({ rules: mailRules, mode: mailRuleMode }, automationMailRuleSet(item)), priority: Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50, })) .sort((a, b) => { @@ -683,7 +564,7 @@ export default function AutomationPage() { const mailRuleTestResult = useMemo(() => { if (trigger !== "邮件触发") return null; - const message: MailRuleTestMessage = { + const message: MailRuleMessage = { from: mailTestFrom, to: mailTestTo, subject: mailTestSubject, @@ -692,10 +573,10 @@ export default function AutomationPage() { }; const currentRuleResults = mailRules.map((rule) => ({ rule, - matched: doesMailRuleTestMatch(rule, message), - source: mailRuleTestSource(rule, message), + matched: doesMailRuleMatch(rule, message), + source: mailRuleSource(rule, message), })); - const currentMatched = doMailRulesTestMatch(mailRules, mailRuleMode, message); + const currentMatched = doesMailRuleSetMatch({ rules: mailRules, mode: mailRuleMode }, message); const currentId = editingAutomationId ?? Number.MAX_SAFE_INTEGER; const candidates = automations @@ -703,13 +584,7 @@ export default function AutomationPage() { if (item.id === editingAutomationId) return false; return item.trigger === "邮件触发" && item.status === "running"; }) - .filter((item) => - doMailRulesTestMatch( - Array.isArray(item.mailRules) ? item.mailRules : [], - item.mailRuleMode === "any" ? "any" : "all", - message, - ), - ) + .filter((item) => doesMailRuleSetMatch(automationMailRuleSet(item), message)) .map((item) => ({ id: item.id, name: item.name, @@ -1100,7 +975,7 @@ export default function AutomationPage() { function localizedTriggerDetail(item: Automation) { if (item.trigger === "邮件触发") { - const ruleText = mailRulesSummary(item); + const ruleText = mailRulesSummary(automationMailRuleSet(item)); const priority = Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50; return `${tt("系统邮箱", "System Mailbox")} · ${ruleText} · ${tt("优先级", "Priority")} ${priority}`; } @@ -1290,12 +1165,8 @@ export default function AutomationPage() { return `每天 ${scheduleTime} · ${timeZoneLabel(scheduleTimezone)}`; } if (trigger === "邮件触发") { - const temp: Automation = { - id: 0, name: "", trigger: "邮件触发", triggerDetail: "", appId: null, agent: "", - strategy, status: "running", statusText: "", time: "", task: "", returnDetail: "", - mailboxLabel: "系统邮箱", mailRuleMode, mailRules, mailPriority, - }; - return `${tt("系统邮箱", "System Mailbox")} · ${mailRulesSummary(temp)} · ${tt("优先级", "Priority")} ${mailPriority}`; + const ruleText = mailRulesSummary({ rules: mailRules, mode: mailRuleMode }); + return `${tt("系统邮箱", "System Mailbox")} · ${ruleText} · ${tt("优先级", "Priority")} ${mailPriority}`; } if (trigger === "Webhook / API") { return "由外部系统通过 Webhook / API 触发"; diff --git a/lib/automation/mail-rules.ts b/lib/automation/mail-rules.ts new file mode 100644 index 0000000..1eb9e2c --- /dev/null +++ b/lib/automation/mail-rules.ts @@ -0,0 +1,222 @@ +/** + * 邮件触发规则匹配的唯一实现(模块 D.6)。 + * + * 服务端调度器(lib/cron/automation-scheduler.ts)与前端向导页 + * (app/automation/page.tsx,"use client")都从这里取规则逻辑,因此 + * 「向导里预览的命中结果」与「调度器实际执行的结果」不可能分叉。 + * + * 同构约束:本模块会被打进客户端 bundle,只允许纯 TypeScript 与纯函数, + * 禁止引入 lib/env、pg、node:* 等仅服务端可用的依赖。 + */ + +export type MailRuleMode = "all" | "any"; + +/** 规范化字段清单:前端下拉与规则取值共用同一份。 */ +export const MAIL_RULE_FIELDS = [ + "发件人", + "发件人域名", + "收件人", + "邮件主题", + "邮件正文", + "是否包含附件", + "附件名称", + "附件类型", +] as const; + +export type MailRuleField = (typeof MAIL_RULE_FIELDS)[number]; + +/** 规范化操作符清单。 */ +export const MAIL_RULE_OPERATORS = [ + "等于", + "包含", + "不包含", + "开头是", + "结尾是", + "是否存在", +] as const; + +export type MailRuleOperator = (typeof MAIL_RULE_OPERATORS)[number]; + +/** + * 单条规则。字段与操作符来自 JSON 配置,落库前未做运行时校验, + * 因此类型上保持宽松(string),可选值由上面的两个清单约束。 + */ +export type MailTriggerRule = { + id?: string; + field: string; + operator: string; + value?: string; +}; + +/** 参与匹配的邮件视图:调度器的 InboxMessage 与前端的测试邮件都是它的超集。 */ +export type MailRuleMessage = { + from?: string; + to?: string; + subject?: string; + body?: string; + attachments?: string[]; +}; + +/** + * 规范化后的规则集输入:调度器从 task.trigger_config 取,前端从 Automation 取, + * 字段名不同但语义一致,各调用点自行组装,规则逻辑本身只有这一份。 + */ +export type MailRuleSet = { + rules?: readonly MailTriggerRule[] | null; + mode?: MailRuleMode; +}; + +/** 规则列表兜底:配置来自 JSON,可能不是数组。 */ +function mailRuleList(set: MailRuleSet): readonly MailTriggerRule[] { + return Array.isArray(set.rules) ? set.rules : []; +} + +/** 模式兜底:只认 "any",其余(含缺省)一律按 "all" 处理。 */ +function normalizeMailRuleMode(mode?: MailRuleMode): MailRuleMode { + return mode === "any" ? "any" : "all"; +} + +/** 取发件人域名:第一个 @ 之后、到 > 空格 逗号 分号 为止,统一小写。 */ +export function extractMailSenderDomain(value?: string) { + const match = String(value || "").match(/@([^>\s,;]+)/); + return match?.[1]?.toLowerCase() || ""; +} + +/** 取附件扩展名:逐个取末尾 .ext 并小写,无扩展名的忽略,空格连接。 */ +export function mailAttachmentExtensions(names?: string[]) { + return (Array.isArray(names) ? names : []) + .map((name) => { + const match = String(name) + .toLowerCase() + .match(/(\.[a-z0-9]+)$/i); + return match?.[1] || ""; + }) + .filter(Boolean) + .join(" "); +} + +/** 按字段取出规则要比对的原始文本(保持原大小写,比较时统一小写)。 */ +export function mailRuleSource(rule: MailTriggerRule, message: MailRuleMessage) { + const attachments = Array.isArray(message.attachments) ? message.attachments : []; + switch (rule.field) { + case "发件人": + return String(message.from || ""); + case "发件人域名": + return extractMailSenderDomain(message.from); + case "收件人": + return String(message.to || ""); + case "邮件主题": + return String(message.subject || ""); + case "邮件正文": + return String(message.body || ""); + case "是否包含附件": + return attachments.length > 0 ? "是" : "否"; + case "附件名称": + return attachments.join(" "); + case "附件类型": + return mailAttachmentExtensions(attachments); + default: + return ""; + } +} + +/** + * 单条规则是否命中。 + * + * 「是否存在」与「是否包含附件」走存在性判断:值为 否/false/0/no(或空缺省为是) + * 表示期望不存在;其余操作符按文本比较,大小写不敏感,空值直接不命中。 + */ +export function doesMailRuleMatch(rule: MailTriggerRule, message: MailRuleMessage) { + const source = mailRuleSource(rule, message).toLowerCase(); + const wanted = String(rule.value || "") + .trim() + .toLowerCase(); + + if (rule.operator === "是否存在" || rule.field === "是否包含附件") { + const exists = rule.field === "是否包含附件" ? source === "是" : source.trim().length > 0; + const wantExists = !["否", "false", "0", "no"].includes(wanted || "是"); + return exists === wantExists; + } + + if (!wanted) return false; + if (rule.operator === "等于") return source.trim() === wanted; + if (rule.operator === "包含") return source.includes(wanted); + if (rule.operator === "不包含") return !source.includes(wanted); + if (rule.operator === "开头是") return source.startsWith(wanted); + if (rule.operator === "结尾是") return source.endsWith(wanted); + return false; +} + +/** 规则集是否命中:无规则视为「收到新邮件即触发」;any 为任一命中,否则全部命中。 */ +export function doesMailRuleSetMatch(set: MailRuleSet, message: MailRuleMessage) { + const rules = mailRuleList(set); + if (rules.length === 0) return true; + const results = rules.map((rule) => doesMailRuleMatch(rule, message)); + const mode = normalizeMailRuleMode(set.mode); + return mode === "any" ? results.some(Boolean) : results.every(Boolean); +} + +/** 规则文案:存在性判断不带引号,其余带引号。 */ +export function mailRuleText(rule: MailTriggerRule) { + if (rule.operator === "是否存在" || rule.field === "是否包含附件") { + return `${rule.field}${rule.value || "是"}`; + } + return `${rule.field}${rule.operator}“${rule.value || ""}”`; +} + +/** 规则集摘要:向导与运行详情共用。 */ +export function mailRulesSummary(set: MailRuleSet) { + const rules = mailRuleList(set); + if (rules.length === 0) return "收到新邮件即触发"; + const prefix = normalizeMailRuleMode(set.mode) === "any" ? "任一" : "全部"; + return `${prefix}:${rules.map(mailRuleText).join(";")}`; +} + +/** + * 规则集精简摘要:自动化**列表接口** triggerDetail 用的文案。 + * + * 注意:它与 mailRulesSummary 的输出格式不同(只显示首条 + 条数,且不区分 + * 存在性判断),这是重构前 store.ts 里既有的行为,本次原样迁移、未做统一—— + * 统一会改变接口返回文案,属于行为变更,需另行决策。前端列表页不使用该字段, + * 它由 localizedTriggerDetail 用 mailRulesSummary 重新生成。 + */ +export function mailRulesBriefSummary(rules?: readonly Partial[] | null) { + const list = Array.isArray(rules) ? rules : []; + if (list.length === 0) return "收到新邮件即触发"; + + const first: Partial = list[0] || {}; + const firstText = `${first.field || "邮件"}${first.operator || "包含"}${first.value ? `“${first.value}”` : ""}`; + if (list.length === 1) return firstText; + return `${firstText} 等 ${list.length} 条`; +} + +/** 规则归一化键:字段|操作符|值(去空格 + 小写),用于比较两组规则是否等价。 */ +export function normalizedMailRule(rule: MailTriggerRule) { + return `${rule.field}|${rule.operator}|${String(rule.value || "") + .trim() + .toLowerCase()}`; +} + +/** 两组规则是否等价:模式与条数相同,且归一化后逐条相同(与顺序无关)。 */ +export function mailRuleSetsEqual(left: MailRuleSet, right: MailRuleSet) { + const leftRules = mailRuleList(left); + const rightRules = mailRuleList(right); + + if ( + normalizeMailRuleMode(left.mode) !== normalizeMailRuleMode(right.mode) || + leftRules.length !== rightRules.length + ) { + return false; + } + + const leftKeys = leftRules.map(normalizedMailRule).sort(); + const rightKeys = rightRules.map(normalizedMailRule).sort(); + return leftKeys.every((value, index) => value === rightKeys[index]); +} + +/** 前端冲突提示等级:任一侧没有规则、或规则完全相同,都视为高风险。 */ +export function mailConflictLevel(current: MailRuleSet, other: MailRuleSet) { + if (mailRuleList(current).length === 0 || mailRuleList(other).length === 0) return "high"; + if (mailRuleSetsEqual(current, other)) return "high"; + return "possible"; +} diff --git a/lib/automation/store.ts b/lib/automation/store.ts index 2f88781..e071168 100644 --- a/lib/automation/store.ts +++ b/lib/automation/store.ts @@ -1,5 +1,6 @@ import pool from "@/lib/db"; import { normalizeMailboxId, requireMailboxId } from "@/lib/automation/mailbox-id"; +import { mailRulesBriefSummary } from "@/lib/automation/mail-rules"; import { getUserTenantId } from "@/lib/tenantMapping"; export type AutomationTriggerType = "定时触发" | "邮件触发" | "Webhook / API" | "自动化完成触发"; @@ -711,16 +712,6 @@ function normalizeEmailPriority(value: any) { return Math.max(0, Math.min(100, Math.round(parsed))); } -function emailRuleSummary(config: Record) { - const rules = Array.isArray(config.rules) ? config.rules : []; - if (rules.length === 0) return "收到新邮件即触发"; - - const first = rules[0] || {}; - const firstText = `${first.field || "邮件"}${first.operator || "包含"}${first.value ? `“${first.value}”` : ""}`; - if (rules.length === 1) return firstText; - return `${firstText} 等 ${rules.length} 条`; -} - export async function createAutomation(userId: number, input: any) { await ensureAutomationTables(); const tenantId = await getUserTenantId(userId); @@ -2453,7 +2444,7 @@ export function automationRowToApi(row: any) { row.trigger_type === "定时触发" ? `${scheduleSummary} · ${scheduleTimezone}` : row.trigger_type === "邮件触发" - ? `${config.mailboxLabel || "系统邮箱"} · ${emailRuleSummary(config)} · 优先级 ${normalizeEmailPriority(config.priority)}` + ? `${config.mailboxLabel || "系统邮箱"} · ${mailRulesBriefSummary(config.rules)} · 优先级 ${normalizeEmailPriority(config.priority)}` : row.trigger_type === "Webhook / API" ? "由外部系统通过 Webhook / API 触发" : "上游自动化完成后触发"; diff --git a/lib/cron/automation-scheduler.ts b/lib/cron/automation-scheduler.ts index 4036a94..3d0eed9 100644 --- a/lib/cron/automation-scheduler.ts +++ b/lib/cron/automation-scheduler.ts @@ -27,6 +27,11 @@ import { requireMailboxId, } from "@/lib/automation/mailbox-id"; import { fetchMailboxUnread } from "@/lib/automation/mailbox-client"; +import { + doesMailRuleSetMatch, + type MailRuleSet, + mailRulesSummary, +} from "@/lib/automation/mail-rules"; declare global { // eslint-disable-next-line no-var @@ -52,13 +57,6 @@ type InboxMessage = { attachments?: string[]; }; -type MailTriggerRule = { - id?: string; - field: string; - operator: string; - value?: string; -}; - function isScheduleConfigurationError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ""); return message.startsWith("SCHEDULE_"); @@ -224,76 +222,10 @@ function serverAuthorization(userId: number) { return `Bearer ${token}`; } -function extractSenderDomain(value?: string) { - const match = String(value || "").match(/@([^>\s,;]+)/); - return match?.[1]?.toLowerCase() || ""; -} - -function attachmentExtensions(names?: string[]) { - return (Array.isArray(names) ? names : []) - .map((name) => { - const match = String(name).toLowerCase().match(/(\.[a-z0-9]+)$/i); - return match?.[1] || ""; - }) - .filter(Boolean) - .join(" "); -} - -function mailRuleSource(rule: MailTriggerRule, message: InboxMessage) { - const attachments = Array.isArray(message.attachments) ? message.attachments : []; - switch (rule.field) { - case "发件人": return String(message.from || ""); - case "发件人域名": return extractSenderDomain(message.from); - case "收件人": return String(message.to || ""); - case "邮件主题": return String(message.subject || ""); - case "邮件正文": return String(message.body || ""); - case "是否包含附件": return attachments.length > 0 ? "是" : "否"; - case "附件名称": return attachments.join(" "); - case "附件类型": return attachmentExtensions(attachments); - default: return ""; - } -} - -function doesMailRuleMatch(rule: MailTriggerRule, message: InboxMessage) { - const source = mailRuleSource(rule, message).toLowerCase(); - const wanted = String(rule.value || "").trim().toLowerCase(); - - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - const exists = rule.field === "是否包含附件" ? source === "是" : source.trim().length > 0; - const wantExists = !["否", "false", "0", "no"].includes(wanted || "是"); - return exists === wantExists; - } - - if (!wanted) return false; - if (rule.operator === "等于") return source.trim() === wanted; - if (rule.operator === "包含") return source.includes(wanted); - if (rule.operator === "不包含") return !source.includes(wanted); - if (rule.operator === "开头是") return source.startsWith(wanted); - if (rule.operator === "结尾是") return source.endsWith(wanted); - return false; -} - -function mailAutomationMatches(task: any, message: InboxMessage) { - const config = task.trigger_config || {}; - const rules: MailTriggerRule[] = Array.isArray(config.rules) ? config.rules : []; - if (rules.length === 0) return true; - const results = rules.map((rule) => doesMailRuleMatch(rule, message)); - return config.ruleMode === "any" ? results.some(Boolean) : results.every(Boolean); -} - -function mailRuleText(rule: MailTriggerRule) { - if (rule.operator === "是否存在" || rule.field === "是否包含附件") { - return `${rule.field}${rule.value || "是"}`; - } - return `${rule.field}${rule.operator}“${rule.value || ""}”`; -} - -function mailRulesSummary(task: any) { +// 任务行(automation_tasks)到规范化规则集的适配;规则逻辑本身在 mail-rules.ts。 +function mailRuleSetFromTask(task: any): MailRuleSet { const config = task.trigger_config || {}; - const rules: MailTriggerRule[] = Array.isArray(config.rules) ? config.rules : []; - if (rules.length === 0) return "收到新邮件即触发"; - const prefix = config.ruleMode === "any" ? "任一" : "全部"; - return `${prefix}:${rules.map(mailRuleText).join(";")}`; + return { rules: config.rules, mode: config.ruleMode }; } async function fetchConfiguredMailboxUnread( @@ -349,7 +281,7 @@ async function executeEmailAutomation(task: any, message: InboxMessage) { mailboxId: requireMailboxId(config.mailboxId), mailbox: config.mailboxLabel || "系统邮箱", folder: config.folder || "INBOX", - matchedRule: mailRulesSummary(task), + matchedRule: mailRulesSummary(mailRuleSetFromTask(task)), priority: Number(config.priority ?? 50), from: message.from, to: message.to, @@ -457,7 +389,7 @@ async function processEmailMailboxGroup(tasks: any[]) { if (!subject.startsWith("自动化执行结果:") && !subject.startsWith("[AI对话]")) { const messageKey = String(message.message_id || "").trim() || `uid:${uid}`; const matched = tasks - .filter((task) => mailAutomationMatches(task, message)) + .filter((task) => doesMailRuleSetMatch(mailRuleSetFromTask(task), message)) .sort((a, b) => { const priorityDelta = Number(b.trigger_config?.priority ?? 50) - Number(a.trigger_config?.priority ?? 50); return priorityDelta !== 0 ? priorityDelta : Number(a.id) - Number(b.id); @@ -495,7 +427,7 @@ async function processEmailMailboxGroup(tasks: any[]) { automationId: taskId, outcome, winnerAutomationId: winner ? Number(winner.id) : null, - matchedRule: mailRulesSummary(task), + matchedRule: mailRulesSummary(mailRuleSetFromTask(task)), priority: Number(task.trigger_config?.priority ?? 50), from: message.from, to: message.to, diff --git a/test/mail-rules.test.ts b/test/mail-rules.test.ts new file mode 100644 index 0000000..23ff598 --- /dev/null +++ b/test/mail-rules.test.ts @@ -0,0 +1,442 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + MAIL_RULE_FIELDS, + MAIL_RULE_OPERATORS, + doesMailRuleMatch, + doesMailRuleSetMatch, + extractMailSenderDomain, + mailAttachmentExtensions, + mailConflictLevel, + mailRuleSetsEqual, + mailRuleSource, + mailRulesBriefSummary, + mailRulesSummary, + mailRuleText, + normalizedMailRule, + type MailRuleMessage, + type MailTriggerRule, +} from "../lib/automation/mail-rules.ts"; + +const message: MailRuleMessage = { + from: "张三 ", + to: "sales@ragent.com", + subject: "【询价】服务器采购 2026 Q3", + body: "请报 10 台服务器的价格,详见附件。", + attachments: ["报价单.XLSX", "清单", "readme.md"], +}; + +const emptyMessage: MailRuleMessage = {}; + +function rule(field: string, operator: string, value?: string): MailTriggerRule { + return { field, operator, value }; +} + +// 除「是否包含附件」外的 7 个字段:它们的 source 由测试用例自行推导。 +const VALUE_FIELDS = [ + "发件人", + "发件人域名", + "收件人", + "邮件主题", + "邮件正文", + "附件名称", + "附件类型", +] as const; + +function sourceOf(field: string) { + return mailRuleSource(rule(field, "包含"), message).toLowerCase(); +} + +test("MAIL_RULE_FIELDS / MAIL_RULE_OPERATORS: 规范化清单", () => { + assert.deepEqual( + [...MAIL_RULE_FIELDS], + [ + "发件人", + "发件人域名", + "收件人", + "邮件主题", + "邮件正文", + "是否包含附件", + "附件名称", + "附件类型", + ] + ); + assert.deepEqual( + [...MAIL_RULE_OPERATORS], + ["等于", "包含", "不包含", "开头是", "结尾是", "是否存在"] + ); +}); + +test("mailRuleSource: 各字段取值", () => { + assert.equal(mailRuleSource(rule("发件人", "包含"), message), "张三 "); + assert.equal(mailRuleSource(rule("发件人域名", "包含"), message), "example.com"); + assert.equal(mailRuleSource(rule("收件人", "包含"), message), "sales@ragent.com"); + assert.equal(mailRuleSource(rule("邮件主题", "包含"), message), "【询价】服务器采购 2026 Q3"); + assert.equal( + mailRuleSource(rule("邮件正文", "包含"), message), + "请报 10 台服务器的价格,详见附件。" + ); + assert.equal(mailRuleSource(rule("是否包含附件", "包含"), message), "是"); + assert.equal(mailRuleSource(rule("附件名称", "包含"), message), "报价单.XLSX 清单 readme.md"); + assert.equal(mailRuleSource(rule("附件类型", "包含"), message), ".xlsx .md"); +}); + +test("mailRuleSource: 未知字段返回空串(默认值保持原逻辑)", () => { + assert.equal(mailRuleSource(rule("未知字段", "包含"), message), ""); +}); + +test("mailRuleSource: 空邮件与缺字段均取空值或否定值", () => { + assert.equal(mailRuleSource(rule("发件人", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("收件人", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("邮件主题", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("邮件正文", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("是否包含附件", "包含"), emptyMessage), "否"); + assert.equal(mailRuleSource(rule("附件名称", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("附件类型", "包含"), emptyMessage), ""); + assert.equal(mailRuleSource(rule("是否包含附件", "包含"), { attachments: [] }), "否"); + assert.equal(mailRuleSource(rule("附件名称", "包含"), { attachments: undefined }), ""); +}); + +test("extractMailSenderDomain: 取第一个 @ 之后的域名并小写", () => { + assert.equal(extractMailSenderDomain("a@b.com"), "b.com"); + assert.equal(extractMailSenderDomain("张三 "), "example.com"); + assert.equal(extractMailSenderDomain("A@B.com; C@D.com"), "b.com"); + assert.equal(extractMailSenderDomain("A@B.com,"), "b.com"); + assert.equal(extractMailSenderDomain("A@B.com>"), "b.com"); +}); + +test("extractMailSenderDomain: 无 @ 或空值返回空串", () => { + assert.equal(extractMailSenderDomain("没有邮箱"), ""); + assert.equal(extractMailSenderDomain(""), ""); + assert.equal(extractMailSenderDomain(undefined), ""); +}); + +test("mailAttachmentExtensions: 逐个小写提取末尾扩展名", () => { + assert.equal(mailAttachmentExtensions(["报价单.XLSX", "清单", "readme.md"]), ".xlsx .md"); + assert.equal(mailAttachmentExtensions(["A.PDF", "B.DocX"]), ".pdf .docx"); + assert.equal(mailAttachmentExtensions(["无扩展名", "尾部是点."]), ""); + assert.equal(mailAttachmentExtensions([]), ""); + assert.equal(mailAttachmentExtensions(undefined), ""); +}); + +test("doesMailRuleMatch: 各字段 × 各操作符 —— 命中", () => { + for (const field of VALUE_FIELDS) { + const source = sourceOf(field); + assert.equal( + doesMailRuleMatch(rule(field, "等于", source), message), + true, + `${field} 等于 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "包含", source.slice(0, 4)), message), + true, + `${field} 包含 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "不包含", "zzz-不存在-zzz"), message), + true, + `${field} 不包含 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "开头是", source.slice(0, 4)), message), + true, + `${field} 开头是 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "结尾是", source.slice(-4)), message), + true, + `${field} 结尾是 应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "是否存在", ""), message), + true, + `${field} 是否存在 应命中` + ); + } +}); + +test("doesMailRuleMatch: 各字段 × 各操作符 —— 未命中", () => { + for (const field of VALUE_FIELDS) { + const source = sourceOf(field); + for (const operator of ["等于", "包含", "开头是", "结尾是"] as const) { + assert.equal( + doesMailRuleMatch(rule(field, operator, "绝不匹配的内容zzz"), message), + false, + `${field} ${operator} 不应命中` + ); + } + assert.equal( + doesMailRuleMatch(rule(field, "不包含", source.slice(0, 4)), message), + false, + `${field} 不包含 不应命中` + ); + assert.equal( + doesMailRuleMatch(rule(field, "是否存在", "否"), message), + false, + `${field} 是否存在 否 不应命中` + ); + } +}); + +test("doesMailRuleMatch: 未知操作符一律不命中", () => { + assert.equal(doesMailRuleMatch(rule("邮件主题", "正则匹配", "询价"), message), false); +}); + +test("doesMailRuleMatch: 是否包含附件强制按存在性判断,忽略操作符", () => { + for (const operator of MAIL_RULE_OPERATORS) { + assert.equal( + doesMailRuleMatch(rule("是否包含附件", operator, "是"), message), + true, + `${operator} + 是 应命中` + ); + assert.equal( + doesMailRuleMatch(rule("是否包含附件", operator, ""), message), + true, + `${operator} + 空值(默认是)应命中` + ); + assert.equal( + doesMailRuleMatch(rule("是否包含附件", operator, "否"), message), + false, + `${operator} + 否 不应命中` + ); + } +}); + +test("是否存在: 否定取值判定为「不期望存在」", () => { + for (const value of ["否", "false", "0", "no", "NO", "False"]) { + assert.equal( + doesMailRuleMatch(rule("邮件主题", "是否存在", value), message), + false, + `${value} 应判定为不期望存在` + ); + } + for (const value of ["是", "true", "1", "yes", "任意其他"]) { + assert.equal( + doesMailRuleMatch(rule("邮件主题", "是否存在", value), message), + true, + `${value} 应判定为期望存在` + ); + } +}); + +test("是否存在: 空值字段(邮箱缺失)判定为不存在", () => { + assert.equal(doesMailRuleMatch(rule("发件人域名", "是否存在", "是"), emptyMessage), false); + assert.equal(doesMailRuleMatch(rule("发件人域名", "是否存在", "否"), emptyMessage), true); +}); + +test("是否包含附件: 无附件时按不存在处理", () => { + assert.equal(doesMailRuleMatch(rule("是否包含附件", "是否存在", "是"), emptyMessage), false); + assert.equal(doesMailRuleMatch(rule("是否包含附件", "是否存在", "否"), emptyMessage), true); +}); + +test("边界值: 空值/缺失值的规则一律不命中", () => { + assert.equal(doesMailRuleMatch(rule("邮件主题", "包含", ""), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "包含", " "), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "包含"), message), false); + // 空值在「不包含」上同样直接判定为不命中(守卫先于操作符分派,保持原逻辑)。 + assert.equal(doesMailRuleMatch(rule("邮件主题", "不包含", ""), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "等于", ""), message), false); + assert.equal(doesMailRuleMatch(rule("邮件主题", "开头是", " "), message), false); +}); + +test("大小写: 字段值与规则值均按小写比较", () => { + assert.equal(doesMailRuleMatch(rule("发件人", "包含", "ZHANG.SAN"), message), true); + assert.equal( + doesMailRuleMatch(rule("发件人", "等于", "张三 "), message), + true + ); + assert.equal(doesMailRuleMatch(rule("邮件主题", "结尾是", "q3"), message), true); + assert.equal(doesMailRuleMatch(rule("附件名称", "包含", ".XLSX"), message), true); + assert.equal(doesMailRuleMatch(rule("发件人", "包含", "zhang.san@example.org"), message), false); +}); + +test("附件类型: 通过扩展名匹配", () => { + assert.equal(doesMailRuleMatch(rule("附件类型", "包含", ".md"), message), true); + assert.equal(doesMailRuleMatch(rule("附件类型", "等于", ".xlsx .md"), message), true); + assert.equal(doesMailRuleMatch(rule("附件类型", "包含", ".docx"), message), false); +}); + +test("doesMailRuleSetMatch: 空规则集恒命中(与模式无关)", () => { + assert.equal(doesMailRuleSetMatch({ rules: [] }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [], mode: "any" }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: null }, message), true); + assert.equal(doesMailRuleSetMatch({}, message), true); +}); + +test("doesMailRuleSetMatch: AND 模式(all)", () => { + const hit = rule("邮件主题", "包含", "询价"); + const hit2 = rule("发件人域名", "等于", "example.com"); + const miss = rule("收件人", "包含", "不存在的收件人"); + + assert.equal(doesMailRuleSetMatch({ rules: [hit, hit2], mode: "all" }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [hit, hit2] }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [hit, miss], mode: "all" }, message), false); + assert.equal(doesMailRuleSetMatch({ rules: [hit, miss] }, message), false); +}); + +test("doesMailRuleSetMatch: OR 模式(any)", () => { + const hit = rule("邮件主题", "包含", "询价"); + const miss = rule("收件人", "包含", "不存在的收件人"); + const miss2 = rule("附件类型", "包含", ".docx"); + + assert.equal(doesMailRuleSetMatch({ rules: [hit, miss], mode: "any" }, message), true); + assert.equal(doesMailRuleSetMatch({ rules: [miss, miss2], mode: "any" }, message), false); +}); + +test("doesMailRuleSetMatch: 规则集缺失/非数组时视为无规则", () => { + assert.equal(doesMailRuleSetMatch({ rules: undefined, mode: "any" }, message), true); + assert.equal( + doesMailRuleSetMatch({ rules: "not-an-array" as unknown as MailTriggerRule[] }, message), + true + ); +}); + +test("mailRuleText: 普通操作符带引号,存在性判断不加引号", () => { + assert.equal( + mailRuleText(rule("发件人域名", "等于", "example.com")), + "发件人域名等于“example.com”" + ); + assert.equal(mailRuleText(rule("邮件主题", "包含", "询价")), "邮件主题包含“询价”"); + assert.equal(mailRuleText(rule("邮件主题", "是否存在", "否")), "邮件主题否"); + assert.equal(mailRuleText(rule("邮件主题", "是否存在", "")), "邮件主题是"); +}); + +test("mailRuleText: 是否包含附件字段同样按存在性文案渲染", () => { + assert.equal(mailRuleText(rule("是否包含附件", "包含", "是")), "是否包含附件是"); + assert.equal(mailRuleText(rule("是否包含附件", "包含", "否")), "是否包含附件否"); + assert.equal(mailRuleText(rule("是否包含附件", "包含")), "是否包含附件是"); +}); + +test("mailRuleText: 值缺失时渲染为空串而非 undefined", () => { + assert.equal(mailRuleText(rule("邮件主题", "包含")), "邮件主题包含“”"); + assert.equal(mailRuleText(rule("邮件主题", "包含", "")), "邮件主题包含“”"); +}); + +test("mailRulesSummary: 无规则时提示收到即触发", () => { + assert.equal(mailRulesSummary({ rules: [] }), "收到新邮件即触发"); + assert.equal(mailRulesSummary({ rules: [], mode: "any" }), "收到新邮件即触发"); + assert.equal(mailRulesSummary({}), "收到新邮件即触发"); +}); + +test("mailRulesSummary: 全部/任一前缀与多条规则的连接", () => { + const first = rule("邮件主题", "包含", "询价"); + const second = rule("发件人域名", "等于", "example.com"); + + assert.equal(mailRulesSummary({ rules: [first], mode: "all" }), "全部:邮件主题包含“询价”"); + assert.equal(mailRulesSummary({ rules: [first] }), "全部:邮件主题包含“询价”"); + assert.equal(mailRulesSummary({ rules: [first], mode: "any" }), "任一:邮件主题包含“询价”"); + assert.equal( + mailRulesSummary({ rules: [first, second], mode: "all" }), + "全部:邮件主题包含“询价”;发件人域名等于“example.com”" + ); + assert.equal( + mailRulesSummary({ rules: [first, second], mode: "any" }), + "任一:邮件主题包含“询价”;发件人域名等于“example.com”" + ); +}); + +test("mailRulesBriefSummary: 列表接口 triggerDetail 用的精简摘要(保留原有格式)", () => { + // 该格式与 mailRulesSummary 不同(只展示首条 + 条数),是重构前 store.ts + // 既有行为,本次原样迁移、未统一,避免改变接口返回文案。 + const first = rule("邮件主题", "包含", "询价"); + const second = rule("发件人域名", "等于", "example.com"); + + assert.equal(mailRulesBriefSummary([]), "收到新邮件即触发"); + assert.equal(mailRulesBriefSummary(null), "收到新邮件即触发"); + assert.equal(mailRulesBriefSummary(undefined), "收到新邮件即触发"); + assert.equal(mailRulesBriefSummary([first]), "邮件主题包含“询价”"); + assert.equal(mailRulesBriefSummary([first, second]), "邮件主题包含“询价” 等 2 条"); +}); + +test("mailRulesBriefSummary: 空值规则与缺省字段的兜底", () => { + assert.equal(mailRulesBriefSummary([rule("邮件主题", "包含", "")]), "邮件主题包含"); + assert.equal(mailRulesBriefSummary([rule("", "")]), "邮件包含"); +}); + +test("mailRulesBriefSummary 与 mailRulesSummary 的差异(存在性字段)", () => { + const exists = rule("是否包含附件", "是否存在", "是"); + assert.equal(mailRulesBriefSummary([exists]), "是否包含附件是否存在“是”"); + assert.equal(mailRulesSummary({ rules: [exists], mode: "all" }), "全部:是否包含附件是"); +}); + +test("normalizedMailRule: 字段|操作符|值(去空格 + 小写)", () => { + assert.equal(normalizedMailRule(rule("邮件主题", "包含", " 询价 ")), "邮件主题|包含|询价"); + assert.equal( + normalizedMailRule(rule("发件人域名", "等于", "Example.COM")), + "发件人域名|等于|example.com" + ); + assert.equal(normalizedMailRule(rule("邮件主题", "包含")), "邮件主题|包含|"); +}); + +test("mailRuleSetsEqual: 规则顺序无关,值的大小写与空格无关", () => { + const left = rule("邮件主题", "包含", "询价"); + const right = rule("发件人域名", "等于", "example.com"); + + assert.equal( + mailRuleSetsEqual({ rules: [left, right], mode: "all" }, { rules: [right, left], mode: "all" }), + true + ); + assert.equal( + mailRuleSetsEqual( + { rules: [rule("邮件主题", "包含", " 询价 ")], mode: "all" }, + { rules: [rule("邮件主题", "包含", "询价")], mode: "all" } + ), + true + ); +}); + +test("mailRuleSetsEqual: 模式、条数、内容不同均判定为不相等", () => { + const left = rule("邮件主题", "包含", "询价"); + const right = rule("发件人域名", "等于", "example.com"); + + assert.equal( + mailRuleSetsEqual({ rules: [left], mode: "all" }, { rules: [left], mode: "any" }), + false + ); + assert.equal( + mailRuleSetsEqual({ rules: [left], mode: "all" }, { rules: [left, right], mode: "all" }), + false + ); + assert.equal( + mailRuleSetsEqual({ rules: [left], mode: "all" }, { rules: [right], mode: "all" }), + false + ); + assert.equal(mailRuleSetsEqual({ rules: [], mode: "all" }, { rules: [], mode: "all" }), true); +}); + +test("mailRuleSetsEqual: 模式缺省按 all 处理", () => { + const left = rule("邮件主题", "包含", "询价"); + assert.equal(mailRuleSetsEqual({ rules: [left] }, { rules: [left], mode: "all" }), true); + assert.equal(mailRuleSetsEqual({ rules: [left] }, { rules: [left], mode: "any" }), false); +}); + +test("mailConflictLevel: 任一侧无规则视为高风险冲突", () => { + const left = rule("邮件主题", "包含", "询价"); + assert.equal( + mailConflictLevel({ rules: [], mode: "all" }, { rules: [left], mode: "all" }), + "high" + ); + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [], mode: "all" }), + "high" + ); + assert.equal(mailConflictLevel({ rules: [] }, {}), "high"); +}); + +test("mailConflictLevel: 规则集完全相同为高风险,否则为可能冲突", () => { + const left = rule("邮件主题", "包含", "询价"); + const other = rule("发件人域名", "等于", "example.com"); + + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [left], mode: "all" }), + "high" + ); + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [other], mode: "all" }), + "possible" + ); + assert.equal( + mailConflictLevel({ rules: [left], mode: "all" }, { rules: [left], mode: "any" }), + "possible" + ); +}); From 06ee3c9cb8ef9138eb840bb420ed9450bf9caf30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 17:33:26 +0800 Subject: [PATCH 13/41] feat(automation): verify mailbox ownership on write and derive label server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 邮件触发的服务端校验(模块 D.2 / D.3)与死代码清理(D.8)。 - createAutomation / updateAutomation 的邮件分支:mailboxId 先归一化为正整数, 再用 getAutomationMailboxForUser(userId, mailboxId) 校验归属;邮箱不存在或 不属于该用户一律抛 MAILBOX_NOT_OWNED,不再有 "system" 特例 - 两个自动化 API 路由把 MAILBOX_NOT_OWNED 映射为 400「监听邮箱不存在或无权使用」, 与既有的 MAILBOX_ID_REQUIRED「请为邮件触发选择监听邮箱」并列,不回落 500 - mailboxLabel 改为由邮箱记录派生(mailboxLabelFromRow),客户端传入的 mailboxLabel(含 triggerConfig.mailboxLabel)一律忽略;派生规则与 mailboxes.ts 的 mailboxRowToApi().label 同源,向导选中的邮箱与列表显示 的邮箱不可能分叉 - 新增 test/mailboxLabel.test.ts 覆盖派生规则的名称/邮箱/缺失/伪造字段边界 - 删除死代码:pages/api/automation/check-email.ts、send-email.ts、 pages/api/v1/automation-email/claim.ts(全仓库含文档均无调用方,调度器直接 调用 store 函数)与 app/automation/page.tsx 的 LEGACY_DEMO_AUTOMATION_NAMES (定义处唯一引用,早已无人使用) Co-Authored-By: Claude Code --- app/automation/page.tsx | 9 --- lib/automation/mailbox-id.ts | 21 +++++- lib/automation/mailboxes.ts | 3 +- lib/automation/store.ts | 36 +++++++++-- pages/api/automation/check-email.ts | 88 -------------------------- pages/api/automation/send-email.ts | 81 ------------------------ pages/api/v1/automation-email/claim.ts | 46 -------------- pages/api/v1/automations/[id].ts | 3 + pages/api/v1/automations/index.ts | 3 + test/mailboxLabel.test.ts | 40 ++++++++++++ 10 files changed, 99 insertions(+), 231 deletions(-) delete mode 100644 pages/api/automation/check-email.ts delete mode 100644 pages/api/automation/send-email.ts delete mode 100644 pages/api/v1/automation-email/claim.ts create mode 100644 test/mailboxLabel.test.ts diff --git a/app/automation/page.tsx b/app/automation/page.tsx index ac1b093..70e8b6f 100644 --- a/app/automation/page.tsx +++ b/app/automation/page.tsx @@ -340,15 +340,6 @@ const AUTOMATION_TEMPLATE_EN: Record< }, }; -const LEGACY_DEMO_AUTOMATION_NAMES = new Set([ - "每日经营日报", - "客户询价处理", - "订单风险检测", - "销售日报分析", - "售后投诉分级", - "库存异常检查", -]); - const initialRunRecords: RunRecord[] = []; function StatusBadge({ status, text }: { status: AutomationStatus | RunStatus; text: string }) { const cls = diff --git a/lib/automation/mailbox-id.ts b/lib/automation/mailbox-id.ts index 94bc48b..7702eba 100644 --- a/lib/automation/mailbox-id.ts +++ b/lib/automation/mailbox-id.ts @@ -1,13 +1,19 @@ /** - * 邮件触发监听邮箱的标识处理。 + * 邮件触发监听邮箱的标识与展示名处理。 * * 历史格式 `mailbox:`(以及作为哨兵的 `"system"`)已废弃:mailboxId 一律是 * 指向 `automation_mailboxes` 的正整数。任何读取该标识的地方都不再做隐式回退, * 缺失或非法时抛出可识别的 MAILBOX_ID_REQUIRED,避免静默落到已下线的系统邮箱分支。 + * + * 本模块是零依赖纯函数模块:服务端 store、邮箱记录的 API 映射(`mailboxes.ts`) + * 与 node:test 用例共用它,因此不允许引入 `pg`、`lib/env` 等需要运行环境的依赖。 */ export const MAILBOX_ID_REQUIRED = "MAILBOX_ID_REQUIRED"; +/** 监听邮箱不存在,或不属于当前用户(模块 D.2 归属校验失败)。 */ +export const MAILBOX_NOT_OWNED = "MAILBOX_NOT_OWNED"; + /** 归一化邮箱标识:仅接受正整数,其余(含遗留的 `mailbox:`、`"system"`)返回 null。 */ export function normalizeMailboxId(value: unknown): number | null { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null; @@ -24,3 +30,16 @@ export function requireMailboxId(value: unknown): number { export function mailboxGroupKey(userId: number, mailboxId: number): string { return `${userId}:${mailboxId}`; } + +/** + * 由邮箱记录派生展示名(模块 D.3)。 + * + * 展示名只能来自邮箱记录本身,客户端传上来的 `mailboxLabel` 一律忽略,避免伪造。 + * 规则与邮箱列表接口(`mailboxes.ts` 的 `mailboxRowToApi`)完全一致,因此 + * 「向导里选中的邮箱」与「自动化列表里显示的邮箱」必然是同一个字符串。 + */ +export function mailboxLabelFromRow(row: { name?: unknown; email?: unknown } | null | undefined): string { + const name = String(row?.name ?? ""); + const email = String(row?.email ?? ""); + return name && name !== email ? `${name} · ${email}` : email; +} diff --git a/lib/automation/mailboxes.ts b/lib/automation/mailboxes.ts index 1296ad2..9315d6a 100644 --- a/lib/automation/mailboxes.ts +++ b/lib/automation/mailboxes.ts @@ -1,5 +1,6 @@ import crypto from "crypto"; import pool from "@/lib/db"; +import { mailboxLabelFromRow } from "@/lib/automation/mailbox-id"; import { getUserTenantId } from "@/lib/tenantMapping"; export type AutomationMailboxInput = { @@ -113,7 +114,7 @@ export function mailboxRowToApi(row: any) { imapSecure: row.imap_secure !== false, folder: row.folder || "INBOX", status: row.status || "connected", - label: row.name && row.name !== row.email ? `${row.name} · ${row.email}` : row.email, + label: mailboxLabelFromRow(row), createdAt: row.created_at, updatedAt: row.updated_at || row.created_at, }; diff --git a/lib/automation/store.ts b/lib/automation/store.ts index e071168..3e51289 100644 --- a/lib/automation/store.ts +++ b/lib/automation/store.ts @@ -1,6 +1,12 @@ import pool from "@/lib/db"; -import { normalizeMailboxId, requireMailboxId } from "@/lib/automation/mailbox-id"; +import { + MAILBOX_NOT_OWNED, + mailboxLabelFromRow, + normalizeMailboxId, + requireMailboxId, +} from "@/lib/automation/mailbox-id"; import { mailRulesBriefSummary } from "@/lib/automation/mail-rules"; +import { getAutomationMailboxForUser } from "@/lib/automation/mailboxes"; import { getUserTenantId } from "@/lib/tenantMapping"; export type AutomationTriggerType = "定时触发" | "邮件触发" | "Webhook / API" | "自动化完成触发"; @@ -712,6 +718,19 @@ function normalizeEmailPriority(value: any) { return Math.max(0, Math.min(100, Math.round(parsed))); } +/** + * 取监听邮箱并校验归属(模块 D.2)。 + * + * 邮箱不存在、或存在但不属于该用户,一律拒绝:客户端不得把自动化指向别人的监听邮箱。 + * 归属判断复用 `getAutomationMailboxForUser` 的 `id + created_by_user_id` 过滤,不另写权限逻辑。 + * 不再有 `"system"` 特例——该值只会作为非法 id 被 `requireMailboxId` 拒绝。 + */ +async function requireOwnedMailbox(userId: number, value: unknown) { + const mailbox = await getAutomationMailboxForUser(userId, requireMailboxId(value)); + if (!mailbox) throw new Error(MAILBOX_NOT_OWNED); + return mailbox; +} + export async function createAutomation(userId: number, input: any) { await ensureAutomationTables(); const tenantId = await getUserTenantId(userId); @@ -756,9 +775,11 @@ export async function createAutomation(userId: number, input: any) { Object.assign(triggerConfig, schedule); nextRunAt = status === "running" ? computeNextRunAt(schedule) : null; } else if (triggerType === "邮件触发") { + const mailbox = await requireOwnedMailbox(userId, input.mailboxId ?? input.triggerConfig?.mailboxId); Object.assign(triggerConfig, { - mailboxId: requireMailboxId(input.mailboxId ?? input.triggerConfig?.mailboxId), - mailboxLabel: String(input.mailboxLabel ?? input.triggerConfig?.mailboxLabel ?? "系统邮箱"), + mailboxId: Number(mailbox.id), + // 展示名一律由邮箱记录派生,客户端传入的 mailboxLabel 忽略(模块 D.3) + mailboxLabel: mailboxLabelFromRow(mailbox), folder: String(input.mailFolder ?? input.triggerConfig?.folder ?? "INBOX"), ruleMode: (input.mailRuleMode ?? input.triggerConfig?.ruleMode) === "any" ? "any" : "all", rules: normalizeEmailRules(input.mailRules ?? input.triggerConfig?.rules), @@ -906,9 +927,14 @@ export async function updateAutomation(userId: number, id: number, input: any) { } triggerConfig = schedule; } else if (triggerType === "邮件触发") { + const mailbox = await requireOwnedMailbox( + userId, + input.mailboxId ?? input.triggerConfig?.mailboxId ?? triggerConfig.mailboxId, + ); triggerConfig = { - mailboxId: requireMailboxId(input.mailboxId ?? input.triggerConfig?.mailboxId ?? triggerConfig.mailboxId), - mailboxLabel: String(input.mailboxLabel ?? input.triggerConfig?.mailboxLabel ?? triggerConfig.mailboxLabel ?? "系统邮箱"), + mailboxId: Number(mailbox.id), + // 展示名一律由邮箱记录派生,客户端传入的 mailboxLabel 忽略(模块 D.3) + mailboxLabel: mailboxLabelFromRow(mailbox), folder: String(input.mailFolder ?? input.triggerConfig?.folder ?? triggerConfig.folder ?? "INBOX"), ruleMode: (input.mailRuleMode ?? input.triggerConfig?.ruleMode ?? triggerConfig.ruleMode) === "any" ? "any" : "all", rules: normalizeEmailRules(input.mailRules ?? input.triggerConfig?.rules ?? triggerConfig.rules), diff --git a/pages/api/automation/check-email.ts b/pages/api/automation/check-email.ts deleted file mode 100644 index 6055536..0000000 --- a/pages/api/automation/check-email.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { requireAuth } from "@/lib/auth"; -import { logError } from "@/lib/logError"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!requireAuth(req, res)) return; - - if (req.method !== "GET") { - res.setHeader("Allow", "GET"); - return res.status(405).json({ error: "Method Not Allowed" }); - } - - const rawAfterUid = req.query.after_uid; - let afterUid: string | undefined; - - if (typeof rawAfterUid === "string" && rawAfterUid.trim() !== "") { - const parsed = Number(rawAfterUid); - - if (!Number.isInteger(parsed) || parsed < 0) { - return res.status(400).json({ error: "Invalid after_uid" }); - } - - afterUid = String(parsed); - } - - try { - const backendBaseUrl = - process.env.EXTERNAL_API_BASE_URL || "http://localhost:8010"; - - const authorization = req.headers.authorization; - - const params = new URLSearchParams(); - if (afterUid !== undefined) { - params.set("after_uid", afterUid); - } - - const url = `${backendBaseUrl}/api/v1/email/unread${ - params.toString() ? `?${params.toString()}` : "" - }`; - - const response = await fetch(url, { - method: "GET", - headers: { - ...(authorization ? { Authorization: authorization } : {}), - }, - }); - - const raw = await response.text(); - - let data: any = null; - try { - data = raw ? JSON.parse(raw) : null; - } catch { - data = raw; - } - - if (!response.ok) { - return res.status(response.status).json({ - error: "Email check failed", - detail: - typeof data === "object" && data?.detail - ? data.detail - : typeof data === "string" - ? data - : `Backend returned ${response.status}`, - }); - } - - return res.status(200).json( - data ?? { - success: true, - latest_uid: 0, - messages: [], - }, - ); - } catch (error: any) { - console.error("[Automation Check Email API] Error:", error); - logError(error); - - return res.status(500).json({ - error: "Email check failed", - detail: - process.env.NODE_ENV === "development" - ? error?.message || "Unknown error" - : undefined, - }); - } -} diff --git a/pages/api/automation/send-email.ts b/pages/api/automation/send-email.ts deleted file mode 100644 index 51d8eee..0000000 --- a/pages/api/automation/send-email.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { requireAuth } from "@/lib/auth"; -import { logError } from "@/lib/logError"; -import type { NextApiRequest, NextApiResponse } from "next"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (!requireAuth(req, res)) return; - - if (req.method !== "POST") { - res.setHeader("Allow", "POST"); - return res.status(405).json({ error: "Method Not Allowed" }); - } - - const { title, body, to, is_html = false } = req.body || {}; - - if (!title || typeof title !== "string") { - return res.status(400).json({ error: "Missing title" }); - } - - if (!body || typeof body !== "string") { - return res.status(400).json({ error: "Missing body" }); - } - - if (!to || (typeof to !== "string" && !Array.isArray(to))) { - return res.status(400).json({ error: "Missing to" }); - } - - try { - const backendBaseUrl = - process.env.EXTERNAL_API_BASE_URL || "http://localhost:8010"; - - const authorization = req.headers.authorization; - - const response = await fetch(`${backendBaseUrl}/api/v1/email/send`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(authorization ? { Authorization: authorization } : {}), - }, - body: JSON.stringify({ - title, - body, - to, - is_html, - }), - }); - - const raw = await response.text(); - - let data: any = null; - try { - data = raw ? JSON.parse(raw) : null; - } catch { - data = raw; - } - - if (!response.ok) { - return res.status(response.status).json({ - error: "Email sending failed", - detail: - typeof data === "object" && data?.detail - ? data.detail - : typeof data === "string" - ? data - : `Backend returned ${response.status}`, - }); - } - - return res.status(200).json(data ?? { success: true }); - } catch (error: any) { - console.error("[Automation Send Email API] Error:", error); - logError(error); - - return res.status(500).json({ - error: "Email sending failed", - detail: - process.env.NODE_ENV === "development" - ? error?.message || "Unknown error" - : undefined, - }); - } -} diff --git a/pages/api/v1/automation-email/claim.ts b/pages/api/v1/automation-email/claim.ts deleted file mode 100644 index 8812e94..0000000 --- a/pages/api/v1/automation-email/claim.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { getUserIdFromRequest } from "@/lib/auth"; -import { claimAutomationEmailMessage, getAutomation } from "@/lib/automation/store"; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const userId = getUserIdFromRequest(req); - if (!userId) return res.status(401).json({ detail: "Unauthorized" }); - - if (req.method !== "POST") { - res.setHeader("Allow", ["POST"]); - return res.status(405).json({ detail: "Method Not Allowed" }); - } - - const automationId = Number(req.body?.automationId); - const mailboxId = Number(req.body?.mailboxId); - const messageKey = String(req.body?.messageKey || "").trim(); - - if (!Number.isInteger(automationId) || automationId <= 0) { - return res.status(400).json({ detail: "自动化 ID 无效" }); - } - if (!Number.isInteger(mailboxId) || mailboxId <= 0) { - return res.status(400).json({ detail: "监听邮箱 ID 无效" }); - } - if (!messageKey) { - return res.status(400).json({ detail: "邮件唯一标识不能为空" }); - } - - try { - const automation = await getAutomation(userId, automationId); - if (!automation || automation.trigger_type !== "邮件触发") { - return res.status(404).json({ detail: "邮件触发自动化不存在" }); - } - - const claimed = await claimAutomationEmailMessage( - userId, - mailboxId, - messageKey, - automationId, - ); - - return res.status(200).json({ claimed }); - } catch (error) { - console.error("[Automation Email Claim API] error:", error); - return res.status(500).json({ detail: "邮件去重记录失败" }); - } -} diff --git a/pages/api/v1/automations/[id].ts b/pages/api/v1/automations/[id].ts index c004dc9..d2feb46 100644 --- a/pages/api/v1/automations/[id].ts +++ b/pages/api/v1/automations/[id].ts @@ -77,6 +77,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (code === "MAILBOX_ID_REQUIRED") { return res.status(400).json({ detail: "请为邮件触发选择监听邮箱" }); } + if (code === "MAILBOX_NOT_OWNED") { + return res.status(400).json({ detail: "监听邮箱不存在或无权使用" }); + } const scheduleMessage = scheduleErrorResponse(code); if (scheduleMessage) return res.status(400).json({ detail: scheduleMessage }); diff --git a/pages/api/v1/automations/index.ts b/pages/api/v1/automations/index.ts index f0a76e2..71b9e16 100644 --- a/pages/api/v1/automations/index.ts +++ b/pages/api/v1/automations/index.ts @@ -48,6 +48,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (code === "MAILBOX_ID_REQUIRED") { return res.status(400).json({ detail: "请为邮件触发选择监听邮箱" }); } + if (code === "MAILBOX_NOT_OWNED") { + return res.status(400).json({ detail: "监听邮箱不存在或无权使用" }); + } const scheduleMessage = scheduleErrorResponse(code); if (scheduleMessage) return res.status(400).json({ detail: scheduleMessage }); diff --git a/test/mailboxLabel.test.ts b/test/mailboxLabel.test.ts new file mode 100644 index 0000000..245a93e --- /dev/null +++ b/test/mailboxLabel.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mailboxLabelFromRow } from "../lib/automation/mailbox-id.ts"; + +test("mailboxLabelFromRow: 名称与邮箱不同时派生为「名称 · 邮箱」", () => { + assert.equal( + mailboxLabelFromRow({ name: "售后客服", email: "support@example.com" }), + "售后客服 · support@example.com" + ); +}); + +test("mailboxLabelFromRow: 名称与邮箱相同时只显示邮箱,避免重复", () => { + assert.equal( + mailboxLabelFromRow({ name: "support@example.com", email: "support@example.com" }), + "support@example.com" + ); +}); + +test("mailboxLabelFromRow: 名称为空或缺失时退回邮箱", () => { + assert.equal(mailboxLabelFromRow({ email: "support@example.com" }), "support@example.com"); + assert.equal( + mailboxLabelFromRow({ name: "", email: "support@example.com" }), + "support@example.com" + ); +}); + +test("mailboxLabelFromRow: 记录缺失时不抛错,返回空串而非 undefined", () => { + assert.equal(mailboxLabelFromRow(null), ""); + assert.equal(mailboxLabelFromRow(undefined), ""); +}); + +test("mailboxLabelFromRow: 展示名只来自邮箱记录,客户端字段不参与派生(模块 D.3)", () => { + const row = { + name: "售后客服", + email: "support@example.com", + // 客户端可伪造的同名字段:派生必须忽略它 + mailboxLabel: "财务专用邮箱", + }; + assert.equal(mailboxLabelFromRow(row), "售后客服 · support@example.com"); +}); From b542490ea5ec7a8851cc66d61323f653ff560764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 17:48:38 +0800 Subject: [PATCH 14/41] feat(automation): configure listen mailbox inline in the create wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模块 B 主路径:邮件触发不再写死系统邮箱,改为在向导第 2 步选择或内联配置 监听邮箱;未保存任何邮箱时下拉默认落在「+ 配置新邮箱…」并展开表单,不留 空状态死路。 - 选择器 + 内联表单(地址/名称/服务器/端口/账号/密码/文件夹),「测试连接」 调 POST /api/v1/automation-mailboxes(服务端保存前真实连接 IMAP),成功后 用响应 id 作为本次自动化的 mailboxId;失败原因内联展示,不提供跳过路径。 - buildAutomationPayload / triggerDetail / localizedTriggerDetail 的监听邮箱 一律由选择派生;遗留行(无整数 mailboxId)显示「监听邮箱未配置」而非回退 系统邮箱,编辑时会要求重新选择而不是静默下发空值。 - resetWizard 默认选中第一条已保存邮箱(模板同样走这条默认)。 - mailFolder 跟随所选邮箱记录的 folder(调度器实际使用的就是它)。 - D.7:新增纯函数 selectMailboxScopedAutomations,按 mailboxId 相等过滤并在 邮箱选择变化时重算,修正跨邮箱误报冲突与错误的 winner 预测。 Co-Authored-By: Claude Code --- app/automation/page.tsx | 399 +++++++++++++++++++++++++++---- lib/automation/mailbox-form.ts | 80 +++++++ lib/automation/mailbox-id.ts | 88 +++++++ test/automationMailboxId.test.ts | 108 +++++++++ test/mailboxForm.test.ts | 90 +++++++ 5 files changed, 724 insertions(+), 41 deletions(-) create mode 100644 lib/automation/mailbox-form.ts create mode 100644 test/mailboxForm.test.ts diff --git a/app/automation/page.tsx b/app/automation/page.tsx index 70e8b6f..7b7c13a 100644 --- a/app/automation/page.tsx +++ b/app/automation/page.tsx @@ -34,6 +34,22 @@ import { type MailRuleSet, type MailTriggerRule, } from "@/lib/automation/mail-rules"; +import { + MAILBOX_NEW_OPTION, + automationMailboxLabel, + mailboxOptionLabel, + mailboxSelectValue, + normalizeMailboxId, + selectMailboxScopedAutomations, + type MailboxOption, +} from "@/lib/automation/mailbox-id"; +import { + EMPTY_MAILBOX_FORM, + firstMailboxFormIssue, + mailboxCreatePayload, + type MailboxFormIssue, + type MailboxFormState, +} from "@/lib/automation/mailbox-form"; type AutomationStatus = "running" | "paused" | "error"; type RunStatus = @@ -251,6 +267,12 @@ function splitMailTestAttachments(value: string) { .filter(Boolean); } +// 接口错误文案提取:不使用 any,避免新增 lint 诊断。 +function apiErrorDetail(error: unknown) { + const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail; + return typeof detail === "string" ? detail : ""; +} + const initialAutomations: Automation[] = []; const automationTemplates: AutomationTemplate[] = [ @@ -265,7 +287,7 @@ const automationTemplates: AutomationTemplate[] = [ { id: "customer-inquiry-mail", name: "客户询价处理", - description: "系统邮箱收到新邮件后,由数字员工提取客户需求并生成处理建议。", + description: "监听邮箱收到新邮件后,由数字员工提取客户需求并生成处理建议。", trigger: "邮件触发", strategy: "自动执行", task: "读取新邮件中的发件人、主题、正文和附件信息,提取客户需求、产品、数量与交期,并生成清晰的处理建议。", @@ -289,7 +311,7 @@ const automationTemplates: AutomationTemplate[] = [ { id: "complaint-processing", name: "售后投诉处理", - description: "系统邮箱收到售后邮件后自动分析投诉内容并输出处理建议。", + description: "监听邮箱收到售后邮件后自动分析投诉内容并输出处理建议。", trigger: "邮件触发", strategy: "需要确认后执行", task: "分析新收到的售后邮件,识别投诉类型、紧急程度和核心诉求,并生成建议处理方案。", @@ -315,7 +337,7 @@ const AUTOMATION_TEMPLATE_EN: Record< }, "customer-inquiry-mail": { name: "Customer Inquiry Processing", - description: "When the system mailbox receives a new email, a digital employee extracts customer needs and generates handling suggestions.", + description: "When the monitored mailbox receives a new email, a digital employee extracts customer needs and generates handling suggestions.", task: "Read the sender, subject, body, and attachment information from the new email. Extract customer needs, products, quantity, and delivery date, then generate clear handling suggestions.", }, "order-risk-check": { @@ -330,7 +352,7 @@ const AUTOMATION_TEMPLATE_EN: Record< }, "complaint-processing": { name: "After-sales Complaint Processing", - description: "Automatically analyze after-sales emails received by the system mailbox and generate handling suggestions.", + description: "Automatically analyze after-sales emails received by the monitored mailbox and generate handling suggestions.", task: "Analyze the newly received after-sales email, identify the complaint type, urgency, and core request, and generate a recommended handling plan.", }, "inventory-check": { @@ -497,7 +519,16 @@ export default function AutomationPage() { const [mailResultEmail, setMailResultEmail] = useState(""); const [resultEmailIncludeAttachments, setResultEmailIncludeAttachments] = useState(true); - // 邮件触发固定使用系统设置中的单一系统邮箱,不允许在自动化页面新增或切换邮箱。 + // 监听邮箱由用户自己配置(系统邮箱已下线):mailboxId 指向 automation_mailboxes, + // mailboxFormOpen 表示"正在内联配置新邮箱",此时尚未有可提交的 mailboxId。 + const [mailboxes, setMailboxes] = useState([]); + const [mailboxesLoaded, setMailboxesLoaded] = useState(false); + const [mailboxId, setMailboxId] = useState(null); + const [mailboxFormOpen, setMailboxFormOpen] = useState(false); + const [mailboxForm, setMailboxForm] = useState(EMPTY_MAILBOX_FORM); + const [mailboxFormIssue, setMailboxFormIssue] = useState(null); + const [mailboxSaveError, setMailboxSaveError] = useState(""); + const [mailboxTesting, setMailboxTesting] = useState(false); const [mailRuleMode, setMailRuleMode] = useState("all"); const [mailRules, setMailRules] = useState([]); const [mailPriority, setMailPriority] = useState(50); @@ -532,14 +563,40 @@ export default function AutomationPage() { const [rejectDialogOpen, setRejectDialogOpen] = useState(false); const [rejectionReason, setRejectionReason] = useState(""); + const mailboxPickerValue = mailboxSelectValue(mailboxId, mailboxFormOpen); + + const selectedMailbox = useMemo( + () => (mailboxFormOpen ? null : mailboxes.find((item) => item.id === mailboxId) ?? null), + [mailboxFormOpen, mailboxId, mailboxes], + ); + + // 调度器实际监听的是邮箱记录里的文件夹,这里同步派生,避免运行详情显示错文件夹。 + const mailFolderValue = String(selectedMailbox?.folder || "").trim() || "INBOX"; + + // 该邮箱被多少个邮件自动化引用:与删除接口的依赖检查口径一致。 + const selectedMailboxDependents = useMemo(() => { + if (!selectedMailbox) return 0; + return automations.filter( + (item) => item.trigger === "邮件触发" && normalizeMailboxId(item.mailboxId) === selectedMailbox.id, + ).length; + }, [automations, selectedMailbox]); + + // D.7:冲突检测与命中预测只在同一监听邮箱内比较——调度器的分组键是 + // `${userId}:${mailboxId}`,跨邮箱比较会误报冲突并预测错误的 winner。 + // 正在配置新邮箱时没有可比较的分组,候选为空。 + const mailboxScopedAutomations = useMemo( + () => + selectMailboxScopedAutomations(automations, { + mailboxId: mailboxFormOpen ? null : mailboxId, + excludeId: editingAutomationId, + }), + [automations, editingAutomationId, mailboxFormOpen, mailboxId], + ); + const mailConflictCandidates = useMemo(() => { if (trigger !== "邮件触发") return []; - return automations - .filter((item) => { - if (item.id === editingAutomationId) return false; - return item.trigger === "邮件触发" && item.status === "running"; - }) + return mailboxScopedAutomations .map((item) => ({ item, level: mailConflictLevel({ rules: mailRules, mode: mailRuleMode }, automationMailRuleSet(item)), @@ -550,7 +607,7 @@ export default function AutomationPage() { if (a.priority !== b.priority) return b.priority - a.priority; return a.item.id - b.item.id; }); - }, [automations, editingAutomationId, mailRuleMode, mailRules, trigger]); + }, [mailRuleMode, mailRules, mailboxScopedAutomations, trigger]); const mailRuleTestResult = useMemo(() => { if (trigger !== "邮件触发") return null; @@ -570,11 +627,7 @@ export default function AutomationPage() { const currentMatched = doesMailRuleSetMatch({ rules: mailRules, mode: mailRuleMode }, message); const currentId = editingAutomationId ?? Number.MAX_SAFE_INTEGER; - const candidates = automations - .filter((item) => { - if (item.id === editingAutomationId) return false; - return item.trigger === "邮件触发" && item.status === "running"; - }) + const candidates = mailboxScopedAutomations .filter((item) => doesMailRuleSetMatch(automationMailRuleSet(item), message)) .map((item) => ({ id: item.id, @@ -605,7 +658,6 @@ export default function AutomationPage() { matchedCandidates: candidates, }; }, [ - automations, editingAutomationId, mailPriority, mailRuleMode, @@ -615,6 +667,7 @@ export default function AutomationPage() { mailTestFrom, mailTestSubject, mailTestTo, + mailboxScopedAutomations, name, trigger, ]); @@ -768,6 +821,38 @@ export default function AutomationPage() { }; }, []); + useEffect(() => { + let alive = true; + + // 监听邮箱列表:供向导内联选择/配置使用。一条都没有(或列表拉取失败)时直接 + // 展开内联表单,避免用户卡在"没有邮箱可选"的空状态。 + async function loadMailboxOptions() { + try { + const response = await axios.get("/api/v1/automation-mailboxes"); + const items = Array.isArray(response.data?.items) + ? (response.data.items as MailboxOption[]) + : []; + if (!alive) return; + + setMailboxes(items); + setMailboxId((current) => + current !== null && items.some((item) => item.id === current) ? current : items[0]?.id ?? null, + ); + setMailboxFormOpen((open) => open || items.length === 0); + } catch (error) { + console.error("加载监听邮箱列表失败:", error); + if (alive) setMailboxFormOpen(true); + } finally { + if (alive) setMailboxesLoaded(true); + } + } + + loadMailboxOptions(); + return () => { + alive = false; + }; + }, []); + useEffect(() => { let alive = true; @@ -964,11 +1049,19 @@ export default function AutomationPage() { { value: "{{trigger.timezone}}", label: tt("触发时区", "Trigger timezone") }, ]; + /** 已保存自动化要显示的监听邮箱名:解析不到时明确说"未配置",不再回退到系统邮箱。 */ + function automationMailboxText(item: Automation) { + return ( + automationMailboxLabel(item, mailboxes) ?? + tt("监听邮箱未配置", "Mailbox not configured") + ); + } + function localizedTriggerDetail(item: Automation) { if (item.trigger === "邮件触发") { const ruleText = mailRulesSummary(automationMailRuleSet(item)); const priority = Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50; - return `${tt("系统邮箱", "System Mailbox")} · ${ruleText} · ${tt("优先级", "Priority")} ${priority}`; + return `${automationMailboxText(item)} · ${ruleText} · ${tt("优先级", "Priority")} ${priority}`; } if (item.trigger === "Webhook / API") { return tt("由外部系统通过 Webhook / API 触发", "Triggered by an external system through Webhook / API"); @@ -1030,6 +1123,82 @@ export default function AutomationPage() { return value; } + function resetMailboxForm() { + setMailboxForm(EMPTY_MAILBOX_FORM); + setMailboxFormIssue(null); + setMailboxSaveError(""); + } + + function handleMailboxSelectionChange(value: string) { + setMailboxSaveError(""); + if (value === MAILBOX_NEW_OPTION) { + // 只展开表单、保留原有选择:取消配置时可以回到原邮箱。 + setMailboxFormOpen(true); + return; + } + + const next = normalizeMailboxId(Number(value)); + if (next === null) return; + setMailboxFormOpen(false); + setMailboxId(next); + } + + function cancelMailboxForm() { + setMailboxFormOpen(false); + resetMailboxForm(); + setMailboxId((current) => current ?? mailboxes[0]?.id ?? null); + } + + function mailboxFormIssueText(issue: MailboxFormIssue) { + if (issue === "email") return tt("请输入正确的邮箱地址", "Please enter a valid email address"); + if (issue === "username") return tt("请填写邮箱登录账号", "Please enter the mailbox login account"); + if (issue === "password") return tt("请填写邮箱授权码或密码", "Please enter the mailbox password"); + if (issue === "imapHost") return tt("请填写 IMAP 服务器", "Please enter the IMAP server"); + return tt("IMAP 端口不正确", "Invalid IMAP port"); + } + + /** + * 保存内联配置的监听邮箱。 + * + * 服务端在写入前会真实连接一次 IMAP,连接失败即 400——不可达的邮箱无法保存, + * 这是有意接受的约束(安全优先):这里只把失败原因显示在表单里,不提供跳过校验 + * 或"仍然保存"的路径。 + */ + async function saveMailboxFromForm() { + const issue = firstMailboxFormIssue(mailboxForm); + setMailboxFormIssue(issue); + setMailboxSaveError(""); + if (issue) return; + + setMailboxTesting(true); + try { + const response = await axios.post("/api/v1/automation-mailboxes", mailboxCreatePayload(mailboxForm)); + const created = response.data as MailboxOption; + const createdId = normalizeMailboxId(created?.id); + if (createdId === null) { + setMailboxSaveError(tt("监听邮箱保存失败", "Failed to save the mailbox")); + return; + } + + setMailboxes((items) => [created, ...items.filter((item) => item.id !== createdId)]); + setMailboxId(createdId); + setMailboxFormOpen(false); + resetMailboxForm(); + toast.success(tt("监听邮箱已保存", "Mailbox saved")); + } catch (error) { + console.error("保存监听邮箱失败:", error); + setMailboxSaveError( + apiErrorDetail(error) || + tt( + "邮箱连接失败,请检查 IMAP 服务器、账号与授权码", + "Connection failed. Check the IMAP server, account, and password.", + ), + ); + } finally { + setMailboxTesting(false); + } + } + function resetWizard() { setStep(1); setName(""); @@ -1050,6 +1219,10 @@ export default function AutomationPage() { setScheduleDate(""); setMailResultEmail(""); setResultEmailIncludeAttachments(true); + // 监听邮箱:默认选中第一条已保存邮箱;一条都没有时直接展开内联表单。 + setMailboxId(mailboxes[0]?.id ?? null); + setMailboxFormOpen(mailboxes.length === 0); + resetMailboxForm(); setMailRuleMode("all"); setMailRules([]); setMailPriority(50); @@ -1101,6 +1274,16 @@ export default function AutomationPage() { setScheduleDate(item.scheduleDate || ""); setMailResultEmail(item.resultEmail || ""); setResultEmailIncludeAttachments(item.resultEmailIncludeAttachments === true); + // 监听邮箱:能解析到已保存邮箱时直接选中;遗留行(旧字符串键,没有整数 + // mailboxId)或名单里已查不到该邮箱时展开配置表单,提示用户重新选择, + // 而不是在提交时静默下发空值(服务端会以「请为邮件触发选择监听邮箱」拒绝)。 + const savedMailboxId = normalizeMailboxId(item.mailboxId); + setMailboxId(savedMailboxId); + setMailboxFormOpen( + savedMailboxId === null || + (mailboxesLoaded && !mailboxes.some((mailbox) => mailbox.id === savedMailboxId)), + ); + resetMailboxForm(); setMailRuleMode(item.mailRuleMode === "any" ? "any" : "all"); setMailRules(Array.isArray(item.mailRules) ? item.mailRules : []); setMailPriority(Number.isFinite(Number(item.mailPriority)) ? Number(item.mailPriority) : 50); @@ -1157,7 +1340,10 @@ export default function AutomationPage() { } if (trigger === "邮件触发") { const ruleText = mailRulesSummary({ rules: mailRules, mode: mailRuleMode }); - return `${tt("系统邮箱", "System Mailbox")} · ${ruleText} · ${tt("优先级", "Priority")} ${mailPriority}`; + const mailboxText = selectedMailbox + ? mailboxOptionLabel(selectedMailbox) + : tt("未选择监听邮箱", "No mailbox selected"); + return `${mailboxText} · ${ruleText} · ${tt("优先级", "Priority")} ${mailPriority}`; } if (trigger === "Webhook / API") { return "由外部系统通过 Webhook / API 触发"; @@ -1168,6 +1354,9 @@ export default function AutomationPage() { } function buildAutomationPayload() { + // 监听邮箱完全由向导内的选择派生:正在配置新邮箱时视为未选择(提交前会被拦下)。 + const mailboxIdForPayload = mailboxFormOpen ? null : normalizeMailboxId(mailboxId); + return { name: name.trim(), appId: selectedApp?.id, @@ -1208,9 +1397,12 @@ export default function AutomationPage() { trigger === "定时触发" && schedulePeriod === "仅一次" ? scheduleDate || undefined : undefined, - // 邮件触发的监听邮箱为整数 mailboxId,由向导内的邮箱选择器下发;选择器接入前不下发该字段,服务端会明确拒绝。 - mailboxLabel: trigger === "邮件触发" ? "系统邮箱" : undefined, - mailFolder: trigger === "邮件触发" ? "INBOX" : undefined, + // 监听邮箱为整数 mailboxId,指向 automation_mailboxes;服务端会校验归属, + // 并按邮箱记录重新派生 mailboxLabel(客户端传入的 label 不作为展示来源)。 + mailboxId: trigger === "邮件触发" ? mailboxIdForPayload ?? undefined : undefined, + mailboxLabel: + trigger === "邮件触发" && selectedMailbox ? mailboxOptionLabel(selectedMailbox) : undefined, + mailFolder: trigger === "邮件触发" ? mailFolderValue : undefined, mailRuleMode: trigger === "邮件触发" ? mailRuleMode : undefined, mailRules: trigger === "邮件触发" ? mailRules : undefined, mailPriority: trigger === "邮件触发" ? mailPriority : undefined, @@ -1223,6 +1415,26 @@ export default function AutomationPage() { }; } + /** 邮件触发的监听邮箱必须来自邮箱列表(服务端同样会校验归属),未选定时不允许保存。 */ + function validateMailboxSelection() { + if (mailboxFormOpen) { + toast.error( + tt( + "请先完成监听邮箱配置并测试连接,再保存自动化", + "Configure and test the monitored mailbox before saving", + ), + ); + return false; + } + + if (normalizeMailboxId(mailboxId) === null) { + toast.error(tt("请为邮件触发选择监听邮箱", "Please choose a monitored mailbox for the email trigger")); + return false; + } + + return true; + } + function validateScheduleConfiguration() { if (trigger !== "定时触发") return true; @@ -1286,6 +1498,11 @@ export default function AutomationPage() { setStep(2); return; } + + if (!validateMailboxSelection()) { + setStep(2); + return; + } } try { @@ -1350,6 +1567,11 @@ export default function AutomationPage() { setStep(2); return; } + + if (!validateMailboxSelection()) { + setStep(2); + return; + } } try { @@ -2882,24 +3104,119 @@ export default function AutomationPage() { {trigger === "邮件触发" && ( <> -
-
-
-
- {tt("系统邮箱(系统设置)", "System Mailbox (System Settings)")} -
-
- {tt( - "邮件触发固定监听管理员在「系统设置」中配置的系统邮箱,不在自动化页面新增或切换邮箱。", - "Email triggers always monitor the system mailbox configured by an administrator in System Settings. Mailboxes cannot be added or switched here.", - )} -
+ + + {selectedMailboxDependents > 0 && !mailboxFormOpen && ( +
+ {tt( + `⚠ 该邮箱已被 ${selectedMailboxDependents} 个自动化使用,修改凭据会影响它们`, + `⚠ This mailbox is used by ${selectedMailboxDependents} automations; changing its credentials affects them all`, + )} +
+ )} + + {mailboxFormOpen && ( +
+
+ {tt("配置新邮箱", "Configure a new mailbox")}
- - {tt("固定监听", "Fixed")} - + +
+ setMailboxForm((form) => ({ ...form, email: e.target.value }))} + className="input-base" + placeholder={tt("邮箱地址,例如 sales@corp.com", "Email address, e.g. sales@corp.com")} + /> + setMailboxForm((form) => ({ ...form, name: e.target.value }))} + className="input-base" + placeholder={tt("名称(选填),例如 销售部邮箱", "Name (optional), e.g. Sales mailbox")} + /> + setMailboxForm((form) => ({ ...form, imapHost: e.target.value }))} + className="input-base" + placeholder={tt("IMAP 服务器,例如 imap.exmail.qq.com", "IMAP server, e.g. imap.exmail.qq.com")} + /> + setMailboxForm((form) => ({ ...form, imapPort: e.target.value }))} + className="input-base" + placeholder={tt("端口,默认 993", "Port, default 993")} + /> + setMailboxForm((form) => ({ ...form, username: e.target.value }))} + className="input-base" + placeholder={tt("登录账号,通常为邮箱地址", "Login account, usually the email address")} + /> + setMailboxForm((form) => ({ ...form, password: e.target.value }))} + className="input-base" + placeholder={tt("密码或授权码", "Password or app password")} + /> + setMailboxForm((form) => ({ ...form, folder: e.target.value }))} + className="input-base" + placeholder={tt("监听文件夹,默认 INBOX", "Monitored folder, default INBOX")} + /> +
+ +
+ + {mailboxes.length > 0 && ( + + )} +
+ +
+ {tt( + "平台会先真实连接该邮箱验证凭据,通过后立即保存并选中;连接失败的邮箱不会被保存。", + "The platform connects to the mailbox to verify the credentials, then saves and selects it. Mailboxes that fail to connect are never saved.", + )} +
+ + {(mailboxFormIssue || mailboxSaveError) && ( +
+ {mailboxFormIssue ? mailboxFormIssueText(mailboxFormIssue) : mailboxSaveError} +
+ )}
-
+ )} +
{tt( "监听邮箱与任务完成后的通知邮箱相互独立。", @@ -2910,12 +3227,12 @@ export default function AutomationPage() {
- {tt("收件箱(INBOX)", "Inbox (INBOX)")} + {mailFolderDisplay(mailboxFormOpen ? mailboxForm.folder : selectedMailbox?.folder)}
{tt( - "当前仅监听系统邮箱的收件箱中新到达的邮件。", - "Currently, only new messages arriving in the system mailbox inbox are monitored.", + "监听文件夹来自所选邮箱的配置,同一邮箱上的所有自动化共用同一文件夹。", + "The monitored folder comes from the selected mailbox; all automations on that mailbox share it.", )}
diff --git a/lib/automation/mailbox-form.ts b/lib/automation/mailbox-form.ts new file mode 100644 index 0000000..b93b1b3 --- /dev/null +++ b/lib/automation/mailbox-form.ts @@ -0,0 +1,80 @@ +/** + * 向导内联邮箱配置的表单状态与请求体(模块 B)。 + * + * 本模块是零依赖纯函数模块:会被打进客户端 bundle,也被 node:test 直接 import + * 来验证"未提供的密码不得退化成空串"这类约束,因此不允许引入 React、axios、 + * lib/env 等依赖。选中的邮箱标识与展示名在 `mailbox-id.ts`,不在本模块。 + */ + +/** 内联新建邮箱表单的可编辑字段:全部按字符串保存,提交时再规整。 */ +export type MailboxFormState = { + name: string; + email: string; + username: string; + password: string; + imapHost: string; + imapPort: string; + folder: string; +}; + +/** 表单初值:端口与文件夹用服务端默认值,其余留空由用户填写。 */ +export const EMPTY_MAILBOX_FORM: MailboxFormState = { + name: "", + email: "", + username: "", + password: "", + imapHost: "", + imapPort: "993", + folder: "INBOX", +}; + +/** 表单必填项的问题代码;调用方负责翻译成文案(与接口错误码一一对应)。 */ +export type MailboxFormIssue = "email" | "username" | "password" | "imapHost" | "imapPort"; + +/** 与邮箱接口 `normalizeInput` 同一套校验规则:先在前端拦下,避免无谓的连接尝试。 */ +export function firstMailboxFormIssue(form: MailboxFormState): MailboxFormIssue | null { + if (!/^\S+@\S+\.\S+$/.test(String(form?.email ?? "").trim())) return "email"; + if (!String(form?.username ?? "").trim()) return "username"; + if (!String(form?.password ?? "")) return "password"; + if (!String(form?.imapHost ?? "").trim()) return "imapHost"; + + const imapPort = Number(form?.imapPort ?? ""); + if (!Number.isInteger(imapPort) || imapPort <= 0 || imapPort > 65535) return "imapPort"; + + return null; +} + +/** 新建邮箱的请求体。 */ +export type MailboxCreatePayload = { + name: string; + email: string; + username: string; + imapHost: string; + imapPort: number; + folder: string; + password?: string; +}; + +/** + * 组装新建邮箱请求体(模块 B 陷阱 1)。 + * + * 密码为空表示"未提供":此时**不下发该字段**,而不是下发空串。服务端对空串会直接 + * 拒绝(MAILBOX_PASSWORD_REQUIRED),而在编辑路径(PUT,模块 C)下"空串"意味着把 + * 凭据覆盖为空、"未提供"意味着保留原凭据——两者必须可区分,否则一次保存就会清掉 + * 共享邮箱的密码。向导只新建、不更新,因此这里恒定走"未提供即省略"的语义。 + */ +export function mailboxCreatePayload(form: MailboxFormState): MailboxCreatePayload { + const payload: MailboxCreatePayload = { + name: String(form?.name ?? "").trim(), + email: String(form?.email ?? "").trim(), + username: String(form?.username ?? "").trim(), + imapHost: String(form?.imapHost ?? "").trim(), + imapPort: Number(form?.imapPort) || 993, + folder: String(form?.folder ?? "").trim() || "INBOX", + }; + + const password = String(form?.password ?? ""); + if (password) payload.password = password; + + return payload; +} diff --git a/lib/automation/mailbox-id.ts b/lib/automation/mailbox-id.ts index 7702eba..42e0943 100644 --- a/lib/automation/mailbox-id.ts +++ b/lib/automation/mailbox-id.ts @@ -43,3 +43,91 @@ export function mailboxLabelFromRow(row: { name?: unknown; email?: unknown } | n const email = String(row?.email ?? ""); return name && name !== email ? `${name} · ${email}` : email; } + +/* + * 以下为向导端("use client")的选择与展示派生逻辑(模块 B / D.7)。 + * 放在本模块是因为它们判断的是同一件事——邮箱标识与展示名,且必须与上面的 + * 分组键、邮箱列表接口(mailboxes.ts 的 mailboxRowToApi)保持一致,不能各写一份。 + */ + +/** 下拉中「+ 配置新邮箱…」的哨兵值:mailboxId 恒为正整数,不会与之冲突。 */ +export const MAILBOX_NEW_OPTION = "new"; + +/** 下拉的显示值:选中已保存邮箱时是它的 id,配置新邮箱时是哨兵值。 */ +export function mailboxSelectValue(mailboxId: number | null, formOpen: boolean): string { + if (formOpen) return MAILBOX_NEW_OPTION; + const id = normalizeMailboxId(mailboxId); + return id === null ? MAILBOX_NEW_OPTION : String(id); +} + +/** 邮箱列表接口(`mailboxes.ts` 的 mailboxRowToApi)返回项的可见字段。 */ +export type MailboxOption = { + id: number; + label?: string; + name?: string; + email?: string; + username?: string; + imapHost?: string; + imapPort?: number; + imapSecure?: boolean; + folder?: string; + status?: string; +}; + +/** 参与邮件触发的自动化最小视图(page.tsx 的 Automation 是它的超集)。 */ +export type MailboxScopedAutomation = { + id: number; + trigger: string; + status: string; + mailboxId?: number | null; +}; + +/** 下拉项展示名:优先用接口给的 label,缺失时按与接口一致的规则派生。 */ +export function mailboxOptionLabel(option: MailboxOption): string { + const label = String(option?.label ?? "").trim(); + return label || mailboxLabelFromRow(option); +} + +/** + * D.7:与当前选择监听同一邮箱、同属邮件触发且正在运行的其他自动化。 + * + * 没有选中邮箱时(含"正在配置新邮箱")返回空候选——此时不存在可比较的分组。 + * `mailboxId` 缺失的遗留行(旧字符串键)不属于任何分组,因此不会被误判为冲突。 + * 与 `mailboxGroupKey` 的范围一致:只有同一 `userId:mailboxId` 才做优先级竞争。 + */ +export function selectMailboxScopedAutomations( + items: readonly T[], + options: { mailboxId: number | null; excludeId?: number | null }, +): T[] { + const mailboxId = normalizeMailboxId(options.mailboxId); + if (mailboxId === null) return []; + + return items.filter( + (item) => + item.id !== options.excludeId && + item.trigger === "邮件触发" && + item.status === "running" && + normalizeMailboxId(item.mailboxId) === mailboxId, + ); +} + +/** + * 自动化列表 / 运行详情要显示的监听邮箱名。 + * + * 返回 null 表示该自动化没有可解析的监听邮箱(遗留行,或名单里查不到且自身也没有 + * 存储名):调用方据此给出「未配置」提示,而不是回退到已下线的"系统邮箱"。 + * 优先按 id 现查名单,邮箱改名后展示名跟着更新;查不到时退回服务端派生的存储名。 + */ +export function automationMailboxLabel( + item: { mailboxId?: number | null; mailboxLabel?: string }, + mailboxes: readonly MailboxOption[], +): string | null { + const mailboxId = normalizeMailboxId(item?.mailboxId); + if (mailboxId === null) return null; + + const matched = mailboxes.find((option) => normalizeMailboxId(option.id) === mailboxId); + if (matched) return mailboxOptionLabel(matched); + + const stored = String(item?.mailboxLabel ?? "").trim(); + return stored || null; +} diff --git a/test/automationMailboxId.test.ts b/test/automationMailboxId.test.ts index 589f3b4..16c3b5a 100644 --- a/test/automationMailboxId.test.ts +++ b/test/automationMailboxId.test.ts @@ -2,9 +2,15 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { MAILBOX_ID_REQUIRED, + MAILBOX_NEW_OPTION, + automationMailboxLabel, mailboxGroupKey, + mailboxOptionLabel, + mailboxSelectValue, normalizeMailboxId, requireMailboxId, + selectMailboxScopedAutomations, + type MailboxOption, } from "../lib/automation/mailbox-id.ts"; test("normalizeMailboxId: 正整数原样返回", () => { @@ -68,3 +74,105 @@ test("mailboxGroupKey: 不同用户或不同邮箱不会落进同一分组", () const keys = new Set([mailboxGroupKey(1, 2), mailboxGroupKey(1, 3), mailboxGroupKey(2, 2)]); assert.equal(keys.size, 3); }); + +// ── 模块 B / D.7:向导邮箱下拉的选择与展示派生 ────────────────────────── + +test("mailboxSelectValue: 选中已保存邮箱时下拉落在该 id 上", () => { + assert.equal(mailboxSelectValue(12, false), "12"); + assert.equal(mailboxSelectValue(12, true), MAILBOX_NEW_OPTION); +}); + +test("mailboxSelectValue: 未选择邮箱或正在配置新邮箱时落在「+ 配置新邮箱…」", () => { + assert.equal(mailboxSelectValue(null, false), MAILBOX_NEW_OPTION); + assert.equal(mailboxSelectValue(null, true), MAILBOX_NEW_OPTION); + assert.equal(mailboxSelectValue(0, false), MAILBOX_NEW_OPTION); + assert.equal(mailboxSelectValue(1.5, false), MAILBOX_NEW_OPTION); +}); + +test("mailboxOptionLabel: 优先用接口 label,缺失时按 name · email 派生", () => { + assert.equal(mailboxOptionLabel({ id: 7, label: "销售部邮箱 · sales@corp.com" }), "销售部邮箱 · sales@corp.com"); + assert.equal(mailboxOptionLabel({ id: 8, name: "财务邮箱", email: "finance@corp.com" }), "财务邮箱 · finance@corp.com"); + assert.equal(mailboxOptionLabel({ id: 9, name: "finance@corp.com", email: "finance@corp.com" }), "finance@corp.com"); +}); + +const mailboxOptions: MailboxOption[] = [ + { id: 7, label: "销售部邮箱 · sales@corp.com", name: "销售部邮箱", email: "sales@corp.com" }, + { id: 8, label: "售后邮箱 · support@corp.com", name: "售后邮箱", email: "support@corp.com" }, +]; + +test("D.7 selectMailboxScopedAutomations: 只返回同一监听邮箱下正在运行的邮件自动化", () => { + const scoped = selectMailboxScopedAutomations( + [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }, + { id: 2, trigger: "邮件触发", status: "running", mailboxId: 8 }, + { id: 3, trigger: "邮件触发", status: "paused", mailboxId: 7 }, + { id: 4, trigger: "定时触发", status: "running", mailboxId: 7 }, + { id: 5, trigger: "邮件触发", status: "running", mailboxId: 7 }, + ], + { mailboxId: 7 }, + ); + + assert.deepEqual(scoped.map((item) => item.id), [1, 5]); +}); + +test("D.7 selectMailboxScopedAutomations: 不同邮箱的自动化互不算冲突", () => { + const items = [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }, + { id: 2, trigger: "邮件触发", status: "running", mailboxId: 8 }, + ]; + + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 7 }).map((item) => item.id), [1]); + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 8 }).map((item) => item.id), [2]); +}); + +test("D.7 selectMailboxScopedAutomations: 未选择邮箱时没有候选", () => { + const items = [{ id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }]; + + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: null }), []); + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 0 }), []); +}); + +test("D.7 selectMailboxScopedAutomations: 遗留行(无整数 mailboxId)不参与任何分组", () => { + const scoped = selectMailboxScopedAutomations( + [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: null }, + { id: 2, trigger: "邮件触发", status: "running" }, + { id: 3, trigger: "邮件触发", status: "running", mailboxId: 7 }, + ], + { mailboxId: 7 }, + ); + + assert.deepEqual(scoped.map((item) => item.id), [3]); +}); + +test("D.7 selectMailboxScopedAutomations: 正在编辑的自动化自身不进入候选", () => { + const items = [ + { id: 1, trigger: "邮件触发", status: "running", mailboxId: 7 }, + { id: 2, trigger: "邮件触发", status: "running", mailboxId: 7 }, + ]; + + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 7, excludeId: 2 }).map((item) => item.id), [1]); + assert.deepEqual(selectMailboxScopedAutomations(items, { mailboxId: 7, excludeId: null }).map((item) => item.id), [1, 2]); +}); + +test("automationMailboxLabel: 按 id 现查名单,邮箱改名后展示名同步更新", () => { + assert.equal( + automationMailboxLabel({ mailboxId: 7, mailboxLabel: "旧名字 · sales@corp.com" }, mailboxOptions), + "销售部邮箱 · sales@corp.com", + ); +}); + +test("automationMailboxLabel: 名单里查不到时退回服务端派生的存储名", () => { + assert.equal( + automationMailboxLabel({ mailboxId: 7, mailboxLabel: "销售部邮箱 · sales@corp.com" }, []), + "销售部邮箱 · sales@corp.com", + ); +}); + +test("automationMailboxLabel: 遗留行不再回退到「系统邮箱」", () => { + // automationRowToApi 对遗留行返回 mailboxId: null,mailboxLabel 兜底为"系统邮箱"。 + assert.equal(automationMailboxLabel({ mailboxId: null, mailboxLabel: "系统邮箱" }, mailboxOptions), null); + assert.equal(automationMailboxLabel({ mailboxId: undefined }, mailboxOptions), null); + assert.equal(automationMailboxLabel({ mailboxId: 0 }, mailboxOptions), null); + assert.equal(automationMailboxLabel({ mailboxId: 7 }, mailboxOptions), "销售部邮箱 · sales@corp.com"); +}); diff --git a/test/mailboxForm.test.ts b/test/mailboxForm.test.ts new file mode 100644 index 0000000..785427d --- /dev/null +++ b/test/mailboxForm.test.ts @@ -0,0 +1,90 @@ +/** + * 向导内联新建邮箱的表单校验与请求体(模块 B)。 + * + * 重点是陷阱 1:密码为空表示"未提供",请求体里必须**没有** password 字段, + * 而不是 password: ""——后者在编辑路径下等同于"把凭据覆盖为空"。 + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + EMPTY_MAILBOX_FORM, + firstMailboxFormIssue, + mailboxCreatePayload, + type MailboxFormState, +} from "../lib/automation/mailbox-form.ts"; + +function form(overrides: Partial = {}): MailboxFormState { + return { + ...EMPTY_MAILBOX_FORM, + email: "sales@corp.com", + username: "sales@corp.com", + password: "auth-code", + imapHost: "imap.corp.com", + ...overrides, + }; +} + +test("EMPTY_MAILBOX_FORM: 端口与文件夹使用服务端默认值", () => { + assert.equal(EMPTY_MAILBOX_FORM.imapPort, "993"); + assert.equal(EMPTY_MAILBOX_FORM.folder, "INBOX"); + assert.equal(firstMailboxFormIssue(form()), null); +}); + +test("表单校验: 填写完整时没有阻断问题", () => { + assert.equal(firstMailboxFormIssue(form({ imapPort: "143", folder: "INBOX/Alert" })), null); +}); + +test("表单校验: 必填项缺失时返回对应问题代码", () => { + assert.equal(firstMailboxFormIssue(form({ email: "" })), "email"); + assert.equal(firstMailboxFormIssue(form({ email: "not-an-email" })), "email"); + assert.equal(firstMailboxFormIssue(form({ username: " " })), "username"); + assert.equal(firstMailboxFormIssue(form({ password: "" })), "password"); + assert.equal(firstMailboxFormIssue(form({ imapHost: "" })), "imapHost"); +}); + +test("表单校验: 端口必须是 1-65535 的整数", () => { + assert.equal(firstMailboxFormIssue(form({ imapPort: "" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "abc" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "0" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "1.5" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "70000" })), "imapPort"); + assert.equal(firstMailboxFormIssue(form({ imapPort: "65535" })), null); +}); + +test("陷阱 1: 密码未填写时请求体不含 password 字段,而不是空串", () => { + const payload = mailboxCreatePayload(form({ password: "" })); + + assert.equal("password" in payload, false); + assert.deepEqual(Object.keys(payload).sort(), [ + "email", + "folder", + "imapHost", + "imapPort", + "name", + "username", + ]); +}); + +test("新建请求体: 填写了密码时原样下发,不做 trim", () => { + // 授权码可能含首尾空格,改动它会导致登录失败。 + assert.equal( + mailboxCreatePayload(form({ password: " pass with space " })).password, + " pass with space " + ); +}); + +test("新建请求体: 文本字段去空格,端口与文件夹回落默认值", () => { + const payload = mailboxCreatePayload( + form({ imapPort: "", folder: " ", imapHost: " imap.corp.com ", email: " sales@corp.com " }) + ); + + assert.equal(payload.imapPort, 993); + assert.equal(payload.folder, "INBOX"); + assert.equal(payload.imapHost, "imap.corp.com"); + assert.equal(payload.email, "sales@corp.com"); +}); + +test("新建请求体: 名称留空时下发空串,由服务端回落到邮箱地址", () => { + assert.equal(mailboxCreatePayload(form({ name: "" })).name, ""); + assert.equal(mailboxCreatePayload(form({ name: " 销售部邮箱 " })).name, "销售部邮箱"); +}); From 3e7adc0f2737bc1f00d12d89c01d4fb063782cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9F=E4=BF=8A=E6=9D=B0?= <2963620948@qq.com> Date: Fri, 11 Sep 2026 17:57:16 +0800 Subject: [PATCH 15/41] fix(automation): never silently rebind a mailbox when the list loads late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审 Findings #1(Minor 升级为必修):邮箱名单加载回调在 current 非空但不在 items 里时会写成 items[0].id。可达路径是"先打开编辑弹窗、名单后返回",结果是界面显示 一个看起来正常的选择,保存即把自动化改绑到另一个收件箱——静默、且监听的邮箱与 用户认知不一致。 改为派生状态,而不是在加载回调里猜用户意图: - mailbox-id.ts 新增 defaultMailboxSelection(只有还没有选择时才用名单第一条) 与 isMailboxSelectionUnresolved(有选择、名单已加载完但查不到它),两者均为 纯函数并有单测(含针对本缺陷的回归用例)。 - page.tsx 用 mailboxFormVisible = mailboxFormOpen || mailboxSelectionUnresolved 统一此前读 mailboxFormOpen 的六处行为点(选择器显示值、选中邮箱、D.7 候选范围、 payload 派生、保存前校验、表单/警示渲染),不可解析时展开表单、给出明确说明并在 保存前拦下,改绑只能由用户显式选择触发。 - 顺带(同一窗口的第二个症状):resetWizard 的展开条件改为 mailboxesLoaded && mailboxes.length === 0,名单未到时不再预先展开表单。 Co-Authored-By: Claude Code --- app/automation/page.tsx | 83 ++++++++++++++++++++++---------- lib/automation/mailbox-id.ts | 35 ++++++++++++++ test/automationMailboxId.test.ts | 44 +++++++++++++++++ 3 files changed, 137 insertions(+), 25 deletions(-) diff --git a/app/automation/page.tsx b/app/automation/page.tsx index 7b7c13a..192a43e 100644 --- a/app/automation/page.tsx +++ b/app/automation/page.tsx @@ -37,6 +37,8 @@ import { import { MAILBOX_NEW_OPTION, automationMailboxLabel, + defaultMailboxSelection, + isMailboxSelectionUnresolved, mailboxOptionLabel, mailboxSelectValue, normalizeMailboxId, @@ -563,11 +565,19 @@ export default function AutomationPage() { const [rejectDialogOpen, setRejectDialogOpen] = useState(false); const [rejectionReason, setRejectionReason] = useState(""); - const mailboxPickerValue = mailboxSelectValue(mailboxId, mailboxFormOpen); + // 有选择、但邮箱名单里查不到它(名单已加载完):遗留行、邮箱被删、或名单拉取失败。 + // 此时必须让用户重新选择——绝不把选择静默换成名单里的第一条,否则一次普通保存 + // 就会把自动化改绑到另一个收件箱。 + const mailboxSelectionUnresolved = isMailboxSelectionUnresolved(mailboxId, mailboxes, mailboxesLoaded); + + // 内联表单是否展开:用户主动展开,或选择不可解析(必须提示重选)。 + const mailboxFormVisible = mailboxFormOpen || mailboxSelectionUnresolved; + + const mailboxPickerValue = mailboxSelectValue(mailboxId, mailboxFormVisible); const selectedMailbox = useMemo( - () => (mailboxFormOpen ? null : mailboxes.find((item) => item.id === mailboxId) ?? null), - [mailboxFormOpen, mailboxId, mailboxes], + () => (mailboxFormVisible ? null : mailboxes.find((item) => item.id === mailboxId) ?? null), + [mailboxFormVisible, mailboxId, mailboxes], ); // 调度器实际监听的是邮箱记录里的文件夹,这里同步派生,避免运行详情显示错文件夹。 @@ -583,14 +593,14 @@ export default function AutomationPage() { // D.7:冲突检测与命中预测只在同一监听邮箱内比较——调度器的分组键是 // `${userId}:${mailboxId}`,跨邮箱比较会误报冲突并预测错误的 winner。 - // 正在配置新邮箱时没有可比较的分组,候选为空。 + // 正在配置新邮箱(或选择不可解析)时没有可比较的分组,候选为空。 const mailboxScopedAutomations = useMemo( () => selectMailboxScopedAutomations(automations, { - mailboxId: mailboxFormOpen ? null : mailboxId, + mailboxId: mailboxFormVisible ? null : mailboxId, excludeId: editingAutomationId, }), - [automations, editingAutomationId, mailboxFormOpen, mailboxId], + [automations, editingAutomationId, mailboxFormVisible, mailboxId], ); const mailConflictCandidates = useMemo(() => { @@ -835,10 +845,12 @@ export default function AutomationPage() { if (!alive) return; setMailboxes(items); - setMailboxId((current) => - current !== null && items.some((item) => item.id === current) ? current : items[0]?.id ?? null, - ); - setMailboxFormOpen((open) => open || items.length === 0); + // 已有选择原样保留(查不到时由 mailboxSelectionUnresolved 让用户重选), + // 只有还没选择过才用名单第一条作默认值。 + setMailboxId((current) => defaultMailboxSelection(current, items)); + // 名单为空必须展开表单(空状态不是死路);名单非空时不改写此前的展开状态, + // 那可能是用户点的"配置新邮箱",也可能是遗留行要求重选。 + if (items.length === 0) setMailboxFormOpen(true); } catch (error) { console.error("加载监听邮箱列表失败:", error); if (alive) setMailboxFormOpen(true); @@ -1219,9 +1231,11 @@ export default function AutomationPage() { setScheduleDate(""); setMailResultEmail(""); setResultEmailIncludeAttachments(true); - // 监听邮箱:默认选中第一条已保存邮箱;一条都没有时直接展开内联表单。 + // 监听邮箱:默认选中第一条已保存邮箱;确认一条都没有时直接展开内联表单。 + // 名单尚未加载完时先不展开——此时"没有邮箱"只是数据还没到,展开会误伤 + // 那些其实有邮箱的用户(名单到位后由 loadMailboxOptions 决定)。 setMailboxId(mailboxes[0]?.id ?? null); - setMailboxFormOpen(mailboxes.length === 0); + setMailboxFormOpen(mailboxesLoaded && mailboxes.length === 0); resetMailboxForm(); setMailRuleMode("all"); setMailRules([]); @@ -1274,15 +1288,12 @@ export default function AutomationPage() { setScheduleDate(item.scheduleDate || ""); setMailResultEmail(item.resultEmail || ""); setResultEmailIncludeAttachments(item.resultEmailIncludeAttachments === true); - // 监听邮箱:能解析到已保存邮箱时直接选中;遗留行(旧字符串键,没有整数 - // mailboxId)或名单里已查不到该邮箱时展开配置表单,提示用户重新选择, - // 而不是在提交时静默下发空值(服务端会以「请为邮件触发选择监听邮箱」拒绝)。 + // 监听邮箱:原样载入该自动化的选择。遗留行(旧字符串键,没有整数 mailboxId) + // 直接展开配置表单提示重选;选中的邮箱若在名单里查不到(不论名单先到还是后到), + // 由 mailboxSelectionUnresolved 派生展开表单并拦下保存,而不是替用户另选一个。 const savedMailboxId = normalizeMailboxId(item.mailboxId); setMailboxId(savedMailboxId); - setMailboxFormOpen( - savedMailboxId === null || - (mailboxesLoaded && !mailboxes.some((mailbox) => mailbox.id === savedMailboxId)), - ); + setMailboxFormOpen(savedMailboxId === null); resetMailboxForm(); setMailRuleMode(item.mailRuleMode === "any" ? "any" : "all"); setMailRules(Array.isArray(item.mailRules) ? item.mailRules : []); @@ -1354,8 +1365,9 @@ export default function AutomationPage() { } function buildAutomationPayload() { - // 监听邮箱完全由向导内的选择派生:正在配置新邮箱时视为未选择(提交前会被拦下)。 - const mailboxIdForPayload = mailboxFormOpen ? null : normalizeMailboxId(mailboxId); + // 监听邮箱完全由向导内的选择派生:正在配置新邮箱、或选择已不可解析时视为未选择 + // (两种情况下提交都会被 validateMailboxSelection 拦下)。 + const mailboxIdForPayload = mailboxFormVisible ? null : normalizeMailboxId(mailboxId); return { name: name.trim(), @@ -1417,6 +1429,16 @@ export default function AutomationPage() { /** 邮件触发的监听邮箱必须来自邮箱列表(服务端同样会校验归属),未选定时不允许保存。 */ function validateMailboxSelection() { + if (mailboxSelectionUnresolved) { + toast.error( + tt( + "原监听邮箱已不存在或不可用,请重新选择监听邮箱", + "The previous monitored mailbox is unavailable. Please choose another one", + ), + ); + return false; + } + if (mailboxFormOpen) { toast.error( tt( @@ -3119,7 +3141,7 @@ export default function AutomationPage() { - {selectedMailboxDependents > 0 && !mailboxFormOpen && ( + {selectedMailboxDependents > 0 && !mailboxFormVisible && (
{tt( `⚠ 该邮箱已被 ${selectedMailboxDependents} 个自动化使用,修改凭据会影响它们`, @@ -3128,12 +3150,21 @@ export default function AutomationPage() {
)} - {mailboxFormOpen && ( + {mailboxFormVisible && (
{tt("配置新邮箱", "Configure a new mailbox")}
+ {mailboxSelectionUnresolved && ( +
+ {tt( + "该自动化原来的监听邮箱已不存在或不可用。请在上方重新选择已有邮箱,或在此配置一个新邮箱。", + "The mailbox this automation used is unavailable. Choose another saved mailbox above, or configure a new one here.", + )} +
+ )} +
- {mailboxes.length > 0 && ( + {/* 选择不可解析时不给"取消"——收起后表单会立刻再次出现(提示必须保留), + 用户只能重新选择一个邮箱。 */} + {mailboxes.length > 0 && !mailboxSelectionUnresolved && ( + + + ) : ( + <> + + + + )} +
+
+ + {editing && ( +
+
+
+ + updateField("name", event.target.value)} + /> +
+ +
+ + updateField("email", event.target.value)} + /> +
+ +
+ + updateField("username", event.target.value)} + /> +
+ +
+ + updateField("password", event.target.value)} + /> + + {t("mailboxManagerPasswordHint")} + +
+ +
+ + updateField("imapHost", event.target.value)} + /> +
+ +
+ + updateField("imapPort", event.target.value)} + /> +
+ +
+ + updateField("folder", event.target.value)} + /> +
+
+ +

+ {t("mailboxManagerCursorHint")} +

+ + {editIssue && ( +

{issueText(editIssue)}

+ )} + {editError &&

{editError}

} + +
+ + +
+
+ )} +
+ ); + })} +
+ + + ); +} diff --git a/app/automation/page.tsx b/app/automation/page.tsx index 192a43e..cec257c 100644 --- a/app/automation/page.tsx +++ b/app/automation/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "@/lib/axios"; import { toast } from "sonner"; import { useLocale } from "next-intl"; @@ -8,6 +8,7 @@ import { Bell, Clock3, Mail, + Settings2, Webhook, GitBranch, LayoutGrid, @@ -18,6 +19,7 @@ import { Trash2, X, } from "lucide-react"; +import MailboxManager from "./components/MailboxManager"; import { MAIL_RULE_FIELDS, MAIL_RULE_OPERATORS, @@ -531,6 +533,7 @@ export default function AutomationPage() { const [mailboxFormIssue, setMailboxFormIssue] = useState(null); const [mailboxSaveError, setMailboxSaveError] = useState(""); const [mailboxTesting, setMailboxTesting] = useState(false); + const [mailboxManagerOpen, setMailboxManagerOpen] = useState(false); const [mailRuleMode, setMailRuleMode] = useState("all"); const [mailRules, setMailRules] = useState([]); const [mailPriority, setMailPriority] = useState(50); @@ -831,40 +834,39 @@ export default function AutomationPage() { }; }, []); - useEffect(() => { - let alive = true; - - // 监听邮箱列表:供向导内联选择/配置使用。一条都没有(或列表拉取失败)时直接 - // 展开内联表单,避免用户卡在"没有邮箱可选"的空状态。 - async function loadMailboxOptions() { - try { - const response = await axios.get("/api/v1/automation-mailboxes"); - const items = Array.isArray(response.data?.items) - ? (response.data.items as MailboxOption[]) - : []; - if (!alive) return; + /** + * 监听邮箱列表:供向导内联选择/配置使用。一条都没有(或列表拉取失败)时直接 + * 展开内联表单,避免用户卡在"没有邮箱可选"的空状态。 + * + * 同时暴露给邮箱管理抽屉:抽屉里改密码/删除后回拉一次,向导的下拉才不会停留在旧名单上 + * (页面的名单只在挂载时拉取一次,见 `mailboxSelectionUnresolved` 对"查不到"的处理)。 + */ + const loadMailboxOptions = useCallback(async () => { + try { + const response = await axios.get("/api/v1/automation-mailboxes"); + const items = Array.isArray(response.data?.items) + ? (response.data.items as MailboxOption[]) + : []; - setMailboxes(items); - // 已有选择原样保留(查不到时由 mailboxSelectionUnresolved 让用户重选), - // 只有还没选择过才用名单第一条作默认值。 - setMailboxId((current) => defaultMailboxSelection(current, items)); - // 名单为空必须展开表单(空状态不是死路);名单非空时不改写此前的展开状态, - // 那可能是用户点的"配置新邮箱",也可能是遗留行要求重选。 - if (items.length === 0) setMailboxFormOpen(true); - } catch (error) { - console.error("加载监听邮箱列表失败:", error); - if (alive) setMailboxFormOpen(true); - } finally { - if (alive) setMailboxesLoaded(true); - } + setMailboxes(items); + // 已有选择原样保留(查不到时由 mailboxSelectionUnresolved 让用户重选), + // 只有还没选择过才用名单第一条作默认值。 + setMailboxId((current) => defaultMailboxSelection(current, items)); + // 名单为空必须展开表单(空状态不是死路);名单非空时不改写此前的展开状态, + // 那可能是用户点的"配置新邮箱",也可能是遗留行要求重选。 + if (items.length === 0) setMailboxFormOpen(true); + } catch (error) { + console.error("加载监听邮箱列表失败:", error); + setMailboxFormOpen(true); + } finally { + setMailboxesLoaded(true); } - - loadMailboxOptions(); - return () => { - alive = false; - }; }, []); + useEffect(() => { + void loadMailboxOptions(); + }, [loadMailboxOptions]); + useEffect(() => { let alive = true; @@ -2371,6 +2373,16 @@ export default function AutomationPage() { )}
+ +