Skill: devincodel/support-router (version 1.0.12)

--- name: support-router description: "客服邮件智能分流。CLI 关键词预筛自动回复常见问题，复杂邮件转发 AI 处理邮箱或主邮箱，节省约 62% token 成本。Use when: (1) setting up automated support email triage for a SaaS product, (2) auto-replying to FAQ emails (pricing, cancellation) without AI, (3) routing bug reports or unclassified emails to an AI-powered mailbox. Requires: mail-cli CLI." metadata: {"openclaw": {"emoji": "🎧", "requires": {"bins": ["mail-cli"]}, "install": [{"id": "npm", "kind": "node", "package": "@clawemail/mail-cli", "bins": ["mail-cli"], "label": "Install mail-cli"}]}} --- # support-router — 客服邮件智能分流 CLI 预筛常见问题（零 token 自动回复），复杂邮件转给 AI 处理邮箱或主邮箱。 ## 依赖 - `mail-cli` CLI 已安装并配置 API Key（参考 mail-cli skill） - Node.js 运行环境（安装 mail-cli 时已具备） - cron（Linux/macOS）或 Task Scheduler（Windows）用于定时轮询 ## 架构概览 ``` 客户发邮件到 support 邮箱 │ 轮询脚本（cron 每分钟） │ 关键词匹配 subject + body │ ┌─────┼──────────┬──────────┬──────────┐ ▼ ▼ ▼ ▼ ▼ 定价 取消订阅 商务合作 Bug/问题 无法分类 │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ 自动 自动 转发到 转发到 转发到 回复 回复 主邮箱 AI 邮箱 AI 邮箱 (0 token)(0 token)(0 token) (需推理) (需推理) │ │ └────┬─────┘ ▼ csbot AI 分析邮件 （归类 + 优先级 + 建议） │ ▼ 发送分析报告给人类 （mainEmail） │ 标记已读 ``` **为什么需要两个邮箱？** support 是对外收件入口，csbot 是 AI 处理邮箱。如果只用一个邮箱，AI 回复后会再次触发路由脚本，形成循环。 **csbot 做什么？** csbot 收到转发的邮件后，AI 会分析归类邮件、评估优先级、给出处理建议，然后将分析报告发送到 `mainEmail`（人类客服邮箱）。csbot **不会直接回复原始客户**，人类看到报告后再决定如何跟进。 ## 搭建步骤 > **⚠️ 步骤顺序很重要：** csbot 的 installcommand 会重启 openclaw，导致当前会话中断。因此本流程将 installcommand 安排在**最后一步**执行——在此之前完成所有配置、测试和定时任务注册。重启后一切已就绪，cron 自动生效，无需额外操作。 ### 1. 配置路由脚本 **先完成配置，再创建邮箱。** 脚本位于本 skill 目录下 `scripts/router.js`（Node.js，跨平台兼容 macOS/Linux/Windows）。 **方式 A：使用 JSON 配置文件（推荐）** 创建配置文件 `router-config.json`： ```json { "supportProfile": "support", "csbotProfile": "csbot", "csbotEmail": "", "mainEmail": "[REDACTED_SECRET]", "productName": "YourProduct", "pricingUrl": "https://yourproduct.com/pricing", "cancelUrl": "https://yourproduct.com/settings", "ignoreSenderDomains": ["claw.163.com"] } ``` > **⛔ mainEmail 必须是人类邮箱地址：** `mainEmail` 用于接收商务合作等需要人工处理的邮件，必须填写人类可直接查看的邮箱（如 `[REDACTED_SECRET]`、`[REDACTED_SECRET]`）。**禁止填写 `@claw.163.com` 结尾的邮箱地址**（那是 mail-cli 的 agent 邮箱）。如果填写了 `@claw.163.com` 地址，商务邮件会被转发到 agent 邮箱而非人类邮箱，人类收不到通知。配置前请询问用户的人类邮箱地址。 运行： ```bash node scripts/router.js --config router-config.json ``` **方式 B：直接修改脚本顶部默认配置** 复制脚本到工作目录后编辑 `DEFAULT_CONFIG` 对象。 如果自动获取 csbot 邮箱失败，手动设置 `csbotEmail` 为 `<workspace>.[REDACTED_SECRET]`。 ### 2. 分类规则验证 使用 `--test-classify` 直接测试分类逻辑，纯本地运算，不需要邮箱也不需要网络： ```bash # 测试定价分类 node scripts/router.js --test-classify --subject "How much does it cost?" --body "I want to know the pricing" # 输出: 分类: pricing / 动作: auto_reply # 测试取消订阅分类 node scripts/router.js --test-classify --subject "cancel subscription" --body "I want to cancel" # 输出: 分类: cancellation / 动作: auto_reply # 测试 Bug 分类 node scripts/router.js --test-classify --subject "App crashes on login" --body "Getting error 500" # 输出: 分类: bug / 动作: forward_csbot / 标签: [Bug反馈] # 测试商务合作分类 node scripts/router.js --test-classify --subject "Partnership opportunity" --body "We want to discuss business cooperation" # 输出: 分类: business / 动作: forward_main # 测试未知分类 node scripts/router.js --test-classify --subject "Hello" --body "Just saying hi" # 输出: 分类: unknown / 动作: forward_csbot # JSON 格式输出（方便程序化验证） node scripts/router.js --test-classify --subject "pricing" --body "how much" --json ``` ### 3. 创建子邮箱 创建两个子邮箱：一个对外收件，一个 AI 处理。 #### 3a. 创建 support 邮箱（对外收件入口） ```bash mail-cli clawemail create --prefix support --type sub --display-name "YourProduct 客服" ``` > **⛔⛔⛔ 禁止执行 installcommand：** 此命令输出会包含一个 `installcommand`（channel 安装命令）。**你绝对不能执行该 installcommand。** support 邮箱由 cron 定时轮询脚本（router.js）处理收件，不需要 channel。如果安装了 channel，AI agent 会与路由脚本同时处理邮件，造成重复回复和逻辑冲突。**不执行 installcommand，立即进入下一步。** #### 3b. 创建 csbot 邮箱（AI 处理邮箱） ```bash mail-cli clawemail create --prefix csbot --type sub --display-name "YourProduct 智能客服" ``` > **⛔⛔⛔ 暂时不要执行 installcommand：** 此命令输出会包含一个 `installcommand`。**先把它记录下来，不要现在执行。** 执行 installcommand 会重启 openclaw，中断当前配置流程。installcommand 安排在最后一步（步骤 7）执行。**把 installcommand 完整保存下来（复制到剪贴板或写入临时文件），继续下一步。** 创建后自动生成 profile（名称 = `--prefix` 的值）： - `support` → `<workspace>.[REDACTED_SECRET]` - `csbot` → `<workspace>.[REDACTED_SECRET]` #### 3c. 验证 ```bash mail-cli clawemail list --json # 应看到 support 和 csbot 邮箱（如装了 jq 可用 mail-cli clawemail list --json | jq '.[].uid'） # 测试 profile 可用 mail-cli --profile support auth test mail-cli --profile csbot auth test ``` ### 4. 端到端验证（dry-run） 发送测试邮件后用 `--dry-run` 验证完整流程（分类 + 动作决策），不实际发送回复： ```bash # 发测试邮件 — 定价咨询 mail-cli compose send --to "<workspace>.[REDACTED_SECRET]" \ --subject "How much does it cost?" --body "I want to know the pricing" # 发测试邮件 — 取消订阅 mail-cli compose send --to "<workspace>.[REDACTED_SECRET]" \ --subject "cancel subscription" --body "I want to cancel my subscription" # dry-run 模式：走完整流程但不发送，内部发件人过滤在 dry-run 下不生效，所以 claw 邮箱发的测试邮件也能正常分类 node scripts/router.js --config router-config.json --dry-run ``` > **⚠️ 关于循环问题：** 正式运行时（非 dry-run），脚本会自动过滤来自 `@claw.163.com` 的内部邮箱发件人（包括 csbot 回复、default 邮箱等），防止 agent 邮箱之间互相触发形成邮件循环。因此 **不要在正式模式下用 claw 邮箱发测试邮件**（会被过滤跳过）。如需正式模式测试，请从外部邮箱（如 Gmail、企业邮箱）发送。 ### 5. 注册定时任务 使用 OpenClaw 定时任务（openclaw schedule）配置每分钟轮询。 ``` schedule: { kind: "cron", expr: "*/1 * * * *", tz: "Asia/Shanghai" } payload: { kind: "agentTurn", message: "执行命令 `node <脚本绝对路径>/router.js --config <配置绝对路径>/router-config.json`，只运行这一条命令，输出结果后结束，不要做任何其他操作。" } sessionTarget: "isolated" delivery: "none" ``` > **⛔⛔⛔ message 措辞决定了 AI 是"执行命令"还是"自由发挥"，必须严格遵守以下规则：** > > **正确写法 — 明确指定要执行的命令：** > ``` > "执行命令 `node /home/user/scripts/router.js --config /home/user/router-config.json`，只运行这一条命令，输出结果后结束，不要做任何其他操作。" > ``` > > **错误写法 — 模糊的任务描述（⛔ 禁止）：** > ``` > ❌ "执行 support-router 轮询客服邮箱" > ❌ "检查客服邮箱并回复邮件" > ❌ "处理 support 收件箱的未读邮件" > ``` > > 模糊描述会让 AI 自行解读任务，可能直接调用 `mail-cli` 拉取邮件并自己生成回复，完全绕过 router.js 脚本的分类逻辑、模板回复和安全机制。 > > **要点：** > 1. message 中必须包含**完整的 `node router.js` 命令**（使用绝对路径） > 2. message 中必须包含**"只运行这一条命令"或"不要做任何其他操作"**等约束语句 > 3. 使用 `sessionTarget: "isolated"` — 每次轮询使用独立 session，避免上下文累积导致 AI 偏离指令 > 4. 使用 `delivery: "none"` — 脚本自行处理邮件收发，不需要调度器额外投递结果。如果不设置，isolated session 默认用 announce 模式，会因缺少目标邮箱而报错 `Delivering to Email requires target mail:[REDACTED_SECRET]`，连续失败后触发退避机制导致调度延迟 > 5. 每次轮询仍会消耗少量 token（AI 读取指令 + 执行命令），但远低于让 AI 自行处理邮件的开销 脚本内置文件锁，即使 cron 重叠触发也不会并行执行。 ### 6. 公布客服地址 将 `<workspace>.[REDACTED_SECRET]` 作为客服邮箱公布到产品网站、帮助文档、邮件签名等。 ### 7. 安装 csbot channel（最后执行，会重启 openclaw） **这是最后一步。** 执行步骤 3b 中保存的 csbot installcommand： ```bash # 执行之前保存的 csbot installcommand（类似以下格式） openclaw channel install --url "..." --name "csbot-channel" ``` > **⚠️ 执行后 openclaw 会重启，当前会话将中断。** 但此时所有配置已完成： > - router-config.json 已创建 ✓ > - support 和 csbot 邮箱已创建 ✓ > - 分类规则已验证 ✓ > - 定时任务已注册 ✓ > > 重启后 cron 定时任务会自动开始轮询 support 收件箱，csbot channel 也已就绪可以接收转发邮件。**无需任何额外操作。** #### installcommand 处理总结 | 邮箱 | installcommand 处理 | 执行时机 | |------|:---:|:---:| | **support**（对外收件） | **⛔ 禁止执行** — 由 cron + router.js 轮询 | — | | **csbot**（AI 处理） | **✅ 必须执行** — 需要 channel 触发 AI 处理 | **步骤 7（最后）** | ## 路由规则 脚本按优先级匹配 subject 和 body（先匹配先处理）： | 优先级 | 匹配关键词（中英文） | 动作 | Token 消耗 | |--------|----------------------|------|-----------| | 1 | `pricing\|price\|费用\|多少钱\|定价\|how much\|报价` | 自动回复定价链接 | 0 | | 2 | `cancel\|退订\|取消\|unsubscribe\|退款` | 自动回复取消步骤 | 0 | | 3 | `合作\|partnership\|商务\|business\|invest\|代理` | 转发到主邮箱 | 0 | | 4 | `bug\|error\|crash\|报错\|崩溃\|打不开\|无法\|故障` | 转发到 AI 邮箱（csbot 分析后报告给人类） | 需推理 | | 5 | （默认）无匹配 | 转发到 AI 邮箱（csbot 分析后报告给人类） | 需推理 | **自定义规则：** 编辑 `router.js` 中的 `RULES` 数组，添加新的正则匹配项。 ## 回复模板 自动回复包含中英文双语内容，适用于国内外用户。 ### 定价回复 ``` 您好！感谢关注 {{PRODUCT_NAME}}。 定价详情请访问：{{PRICING_URL}} 如有其他问题请随时回复此邮件。 Hi! Thanks for your interest in {{PRODUCT_NAME}}. Pricing details: {{PRICING_URL}} Feel free to reply if you have other questions. ``` ### 取消订阅回复 ``` 您好！取消订阅步骤： 1. 登录 {{CANCEL_URL}} 2. 点击「订阅管理」→「取消订阅」 取消后当前计费周期内仍可正常使用。如需帮助请回复此邮件。 To cancel your subscription: 1. Visit {{CANCEL_URL}} 2. Navigate to Subscription Management > Cancel Your service remains active until the end of the billing cycle. ``` **自定义模板：** 编辑 `router.js` 中的 `getPricingReply()` / `getCancellationReply()` 函数。 ## 安全机制 脚本内置多层防护，防止自动回复循环和 cron 冲突： | 机制 | 说明 | |------|------| | 自动回复检测 | 多层识别：`Auto-Submitted`/`X-Auto-Response-Suppress`/`X-Autoreply`/`X-Autorespond` 头、`Precedence: bulk/junk/auto_reply`、空 `Return-Path`（退信）、noreply 发件人、Subject 模式（`Automatic reply`/`自动回复`/`Out of Office`/`退信通知` 等） | | 内部发件人过滤 | 自动跳过 `@claw.163.com` 等内部 agent 邮箱的来信（csbot 的 channel 回复、其他 agent 邮箱等），防止邮件循环。可通过 `ignoreSenderDomains` 配置项自定义。`--dry-run` 模式下不过滤，方便用 claw 邮箱发送测试邮件 | | 频率限制 | 同一地址每天最多自动回复 `maxRepliesPerAddr` 次（默认 100 次，宽松兜底），超限转 AI 邮箱 | | 文件锁互斥 | 防止 cron 重叠执行，同一时间只有一个实例运行（跨平台，无需 flock） | | 已处理记录 | 基于文件的幂等性保护，7 天自动清理 | | csbot 回退 | 如果无法获取 csbot 邮箱地址，自动回退转发到主邮箱 | ## 成本分析 假设每天 40 封客服邮件： | 类型 | 预估数量 | 处理方式 | Token 消耗 | |------|---------|---------|-----------| | 定价咨询 | ~15 | CLI 模板回复 | 0 | | 取消订阅 | ~8 | CLI 模板回复 | 0 | | 商务合作 | ~2 | 转发主邮箱 | 0 | | Bug 反馈 | ~10 | 转发 AI 邮箱 | ~10 次推理 | | 功能建议/其他 | ~5 | 转发 AI 邮箱 | ~5 次推理 | | **合计** | **40** | | **~15 次推理** | 全部走 AI：40 次推理。预筛后：~15 次推理。**节省约 62% token 成本。** ## 关键注意事项 | 事项 | 说明 | |------|------| | `--fid 1` 必须指定 | Claw 账号收件箱 folder ID 为 `1`，所有 search/mark/read 操作必须带 `--fid 1` | | `--profile` 放在命令前面 | `mail-cli --profile support mail search ...` 不是 `mail-cli mail search --profile support ...` | | 用 `--json` 输出 | `node router.js --json` 可输出结构化处理结果，方便程序化集成 | | 回复的 From 地址 | 自动回复从 `support` profile 发出，用户看到的发件人就是客服邮箱 | | 附件不自动转发 | `read body` 只返回文本，转发邮件会提示"原邮件可能包含附件，请查看 support 收件箱" | | 已处理记录存储 | 默认 `~/.local/share/support-router/processed/`，持久化不受重启影响 | | csbot 只发分析报告 | csbot AI 收到转发邮件后，**只将分析归类和处理建议发给 mainEmail（人类）**，不直接回复原始客户 | ## Common Mistakes | 错误 | 修复 | |------|------| | 只创建一个邮箱（混用收件和 AI 处理） | 必须分离：support 收件 + csbot 处理。否则 AI 回复会再次触发路由 | | **对 support 邮箱执行了 installcommand** | **⛔ support 邮箱禁止安装 channel。** 只有 csbot 邮箱需要安装 channel。support 由 cron + router.js 轮询 | | **对 csbot 邮箱忘记执行 installcommand** | **csbot 必须安装 channel**，否则转发到 csbot 的复杂邮件无法触发 AI 处理。installcommand 在步骤 7（最后）执行 | | **过早执行 csbot installcommand 导致配置中断** | installcommand 会重启 openclaw。必须在所有配置、测试、cron 注册完成后（步骤 7）才执行。步骤 3b 中先保存 installcommand，不要立即执行 | | **mainEmail 填了 @claw.163.com 地址** | **mainEmail 必须是人类邮箱**（如 [REDACTED_SECRET]）。@claw.163.com 是 agent 邮箱，人类无法直接查看 | | 忘记 `--fid 1` 导致搜索失败 | Claw 账号所有邮件操作都需要 `--fid 1` | | 关键词只写英文 | 中文用户会发中文邮件，必须同时包含中英文关键词 | | 未分类邮件留在收件箱不处理 | 默认应转发到 AI 邮箱或主邮箱，不要让邮件堆积 | | 使用 `read header --json` 但不知道字段名 | 先手动执行一次确认 JSON 结构：`mail-cli --profile support read header --id <any-id> --fid 1 --json` | | 回复模板硬编码邮箱地址 | 使用配置文件 `router-config.json` 管理所有变量 | | `PROCESSED_DIR` 放在 /tmp 重启后丢失 | 使用默认路径 `~/.local/share/support-router/processed/` 或其他持久目录 | | cron 任务重叠执行导致重复处理 | 脚本已内置文件锁，无需额外处理 | | **cron 中用 `node` 报 `node: not found`** | cron 环境 `PATH` 极简，找不到 `node`/`mail-cli`。在 agentTurn message 中必须使用绝对路径（先 `which node` 获取） | | **定时任务 message 写成模糊任务描述** | **⛔ message 必须包含完整的 `node router.js` 命令。** 写成"轮询客服邮箱"等模糊描述，AI 会自行拉邮件并生成回复，绕过脚本的分类和模板机制 | | **定时任务缺少 `delivery: "none"` 报投递错误** | isolated session 默认用 announce 投递，缺少目标邮箱会报 `Delivering to Email requires target mail:[REDACTED_SECRET]`。加上 `delivery: "none"`，脚本自行处理邮件，不需要调度器投递 | | 只测了定价回复没测取消订阅回复 | 手动测试时必须同时发送 pricing 和 cancellation 测试邮件，确认两种自动回复都正常 | | **正式模式下用 claw 邮箱发测试邮件被跳过** | 正式运行会过滤 `@claw.163.com` 发件人。用 `--dry-run` 或 `--test-classify` 测试，正式模式测试需从外部邮箱发送 | | **csbot 的 channel 回复导致循环** | 脚本自动过滤 csbot 的回复（`@claw.163.com` 发件人过滤），标记已读后跳过。无需额外处理 |

path: scripts/router.js
content: #!/usr/bin/env node
// support-router/scripts/router.js — 客服邮件智能分流脚本
//
// 轮询 support 邮箱，按关键词分类，自动回复或转发。
// 单次轮询模式，由 cron 每分钟调用。
//
// Usage: node router.js [--config router-config.json] [--dry-run] [--json]
//        node router.js --test-classify --subject "..." --body "..."
//
// Requires: mail-cli (npm install -g @clawemail/mail-cli)

const { execSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const os = require("os");

// ========== 默认配置（可通过 --config 文件覆盖）==========
const DEFAULT_CONFIG = {
  supportProfile: "support",
  csbotProfile: "csbot",
  csbotEmail: "",                 // 留空则自动从 mail-cli config 读取
  mainEmail: "[REDACTED_SECRET]",
  productName: "YourProduct",
  pricingUrl: "https://yourproduct.com/pricing",
  cancelUrl: "https://yourproduct.com/settings",
  fid: "1",                      // Claw 收件箱 folder ID
  maxRepliesPerAddr: 100,          // 每地址每天最多自动回复次数（宽松限制，兜底防循环）
  dataDir: "",                    // 留空则使用 ~/.local/share/support-router
  ignoreSenderDomains: ["claw.163.com"],  // 忽略这些域名的发件人（防止内部邮箱循环）
};

// ========== 分类规则（优先级从高到低）==========
const RULES = [
  {
    name: "pricing",
    pattern: /pricing|price|费用|多少钱|定价|how much|报价|plan.*cost|subscription fee/i,
    action: "auto_reply",
  },
  {
    name: "cancellation",
    pattern: /cancel|退订|取消|unsubscribe|退款|refund|close.*account|delete.*account/i,
    action: "auto_reply",
  },
  {
    name: "business",
    pattern: /合作|partnership|商务|business|invest|代理|reseller|enterprise.*inquiry/i,
    action: "forward_main",
  },
  {
    name: "bug",
    pattern: /bug|error|crash|报错|崩溃|打不开|无法|故障|not working|broken|failed|exception/i,
    action: "forward_csbot",
    tag: "[Bug反馈]",
  },
];

// ========== 自动回复邮件检测模式 ==========
const NOREPLY_PATTERN = /noreply|no-reply|mailer-daemon|postmaster/i;
const AUTO_REPLY_SUBJECT_PATTERN = /^(auto:|automatic reply|自动回复|out of office|ooo:|away:|回复：.*自动|退信通知|undeliverable|delivery (status |failure)|returned mail)/i;

// ========== 参数解析 ==========
const args = process.argv.slice(2);
let configFile = null;
let dryRun = false;
let outputJson = false;
let testClassify = false;
let testSubject = "";
let testBody = "";

for (let i = 0; i < args.length; i++) {
  switch (args[i]) {
    case "--config":
      configFile = args[++i];
      break;
    case "--dry-run":
      dryRun = true;
      break;
    case "--json":
      outputJson = true;
      break;
    case "--test-classify":
      testClassify = true;
      break;
    case "--subject":
      testSubject = args[++i] || "";
      break;
    case "--body":
      testBody = args[++i] || "";
      break;
    default:
      console.error(`Unknown option: ${args[i]}`);
      process.exit(1);
  }
}

// ========== 工具函数 ==========

function log(msg) {
  const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
  if (!outputJson) console.log(`[support-router] [${ts}] ${msg}`);
}

function run(cmd, timeout = 30000) {
  try {
    return execSync(cmd, { encoding: "utf8", timeout, stdio: ["pipe", "pipe", "pipe"] });
  } catch (err) {
    const stderr = err.stderr ? err.stderr.toString().trim() : "";
    if (stderr && !outputJson) {
      const ts = new Date().toISOString().replace("T", " ").slice(0, 19);
      console.error(`[support-router] [${ts}] CMD FAILED: ${cmd.slice(0, 120)}...`);
      console.error(`[support-router] [${ts}] STDERR: ${stderr.slice(0, 500)}`);
    }
    return null;
  }
}

function findMailCli() {
  const isWin = process.platform === "win32";
  const whichCmd = isWin ? "where mail-cli" : "which mail-cli";
  const result = run(whichCmd);
  if (result && result.trim()) return "mail-cli";
  return "npx mail-cli";
}

function loadMailCliConfig() {
  const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
  const configPath = path.join(configDir, "mail-cli", "config.json");
  if (fs.existsSync(configPath)) {
    return JSON.parse(fs.readFileSync(configPath, "utf8"));
  }
  const appData = process.env.APPDATA;
  if (appData) {
    const winPath = path.join(appData, "mail-cli", "config.json");
    if (fs.existsSync(winPath)) {
      return JSON.parse(fs.readFileSync(winPath, "utf8"));
    }
  }
  return null;
}

function loadRouterConfig() {
  let cfg = { ...DEFAULT_CONFIG };
  if (configFile) {
    const content = fs.readFileSync(configFile, "utf8");
    const userCfg = JSON.parse(content);
    cfg = { ...cfg, ...userCfg };
  }
  if (!cfg.dataDir) {
    cfg.dataDir = path.join(os.homedir(), ".local", "share", "support-router");
  }
  return cfg;
}

function ensureDir(dir) {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
}

// ========== csbot 邮箱地址解析 ==========

function resolveCsbotEmail(cfg, cli) {
  // 1. 显式配置
  if (cfg.csbotEmail) return cfg.csbotEmail;
  // 2. 从 mail-cli config.json 读取
  const mailConfig = loadMailCliConfig();
  if (mailConfig?.profiles?.[cfg.csbotProfile]?.user) {
    return mailConfig.profiles[cfg.csbotProfile].user;
  }
  // 3. 回退：auth test
  const result = run(`${cli} --profile ${cfg.csbotProfile} auth test`);
  if (result) {
    const match = result.match(/[\w.-]+@claw\.163\.com/);
    if (match) return match[0];
  }
  return null;
}

// ========== 回复频率限制 ==========

function checkReplyLimit(cfg, addr) {
  const today = new Date().toISOString().slice(0, 10);
  const counterFile = path.join(cfg.dataDir, `.reply-count-${addr.replace(/[@.]/g, "_")}-${today}`);
  let count = 0;
  if (fs.existsSync(counterFile)) {
    count = parseInt(fs.readFileSync(counterFile, "utf8"), 10) || 0;
  }
  if (count >= cfg.maxRepliesPerAddr) return false;
  fs.writeFileSync(counterFile, String(count + 1));
  return true;
}

// ========== 自动回复检测 ==========

function isAutoReply(headers, subject) {
  // 1. Auto-Submitted 头（RFC 3834 标准）
  const autoSubmitted = headers["auto-submitted"] || headers["Auto-Submitted"] || "";
  if (autoSubmitted && autoSubmitted !== "no") return true;

  // 2. X-Auto-Response-Suppress 头（Microsoft 系）
  if (headers["x-auto-response-suppress"] || headers["X-Auto-Response-Suppress"]) return true;

  // 3. X-Autoreply / X-Autorespond 头
  if (headers["x-autoreply"] || headers["X-Autoreply"]) return true;
  if (headers["x-autorespond"] || headers["X-Autorespond"]) return true;

  // 4. Precedence: bulk / junk / auto_reply
  const precedence = (headers["precedence"] || headers["Precedence"] || "").toLowerCase();
  if (precedence === "bulk" || precedence === "junk" || precedence === "auto_reply") return true;

  // 5. Return-Path 为空（退信/bounce）— 仅当字段实际存在且值为 <> 时才判定
  const returnPath = headers["return-path"] || headers["Return-Path"];
  if (returnPath && returnPath.trim() === "<>") return true;

  // 6. noreply 发件人
  const from = headers.from || headers.From || "";
  if (NOREPLY_PATTERN.test(from)) return true;

  // 7. Subject 模式（自动回复、Out of Office、退信通知等）
  if (subject && AUTO_REPLY_SUBJECT_PATTERN.test(subject.trim())) return true;

  return false;
}

// ========== 内部发件人检测 ==========

function isInternalSender(fromAddr, cfg) {
  if (!fromAddr || fromAddr === "unknown") return false;
  const addr = fromAddr.toLowerCase();
  // 从发件人字段中提取纯邮箱地址（可能含 "Name <email>" 格式）
  const emailMatch = addr.match(/<([^>]+)>/) || [null, addr];
  const email = emailMatch[1].trim();
  const domains = cfg.ignoreSenderDomains || ["claw.163.com"];
  return domains.some(domain => email.endsWith(`@${domain.toLowerCase()}`));
}

// ========== 邮件分类 ==========

function classifyEmail(subject, body) {
  const combined = `${subject} ${body}`.toLowerCase();
  for (const rule of RULES) {
    if (rule.pattern.test(combined)) {
      return rule;
    }
  }
  return { name: "unknown", action: "forward_csbot", tag: "[待分类]" };
}

// ========== 回复模板（中英文双语）==========

function getPricingReply(cfg) {
  return [
    `您好！感谢关注 ${cfg.productName}。`,
    `定价详情请访问：${cfg.pricingUrl}`,
    `如有其他问题请随时回复此邮件。`,
    ``,
    `Hi! Thanks for your interest in ${cfg.productName}.`,
    `Pricing details: ${cfg.pricingUrl}`,
    `Feel free to reply if you have other questions.`,
  ].join("\n");
}

function getCancellationReply(cfg) {
  return [
    `您好！取消订阅步骤：`,
    `1. 登录 ${cfg.cancelUrl}`,
    `2. 点击「订阅管理」→「取消订阅」`,
    `取消后当前计费周期内仍可正常使用。如需帮助请回复此邮件。`,
    ``,
    `To cancel your subscription:`,
    `1. Visit ${cfg.cancelUrl}`,
    `2. Navigate to Subscription Management > Cancel`,
    `Your service remains active until the end of the billing cycle.`,
  ].join("\n");
}

// ========== 邮件操作 ==========

function sendReply(cli, cfg, toAddr, originalSubject, body) {
  const escapedBody = body.replace(/'/g, "'\\''");
  const escapedSubject = `Re: ${originalSubject}`.replace(/'/g, "'\\''");
  const cmd = `${cli} --profile ${cfg.supportProfile} compose send --to '${toAddr}' --subject '${escapedSubject}' --body '${escapedBody}'`;
  if (dryRun) {
    log(`  [DRY-RUN] 发送回复到 ${toAddr}`);
    return true;
  }
  return run(cmd) !== null;
}

function forwardEmail(cli, cfg, toAddr, fromAddr, originalSubject, body, tag) {
  const fwdBody = [
    `--- 转发自客服邮箱 ---`,
    `From: ${fromAddr}`,
    `Subject: ${originalSubject}`,
    ``,
    body,
    ``,
    `--- NOTE: 原邮件可能包含附件，请直接查看 support 收件箱 ---`,
  ].join("\n");
  const escapedBody = fwdBody.replace(/'/g, "'\\''");
  const subject = `${tag ? tag + " " : ""}Fwd: ${originalSubject}`.replace(/'/g, "'\\''");
  const cmd = `${cli} --profile ${cfg.supportProfile} compose send --to '${toAddr}' --subject '${subject}' --body '${escapedBody}'`;
  if (dryRun) {
    log(`  [DRY-RUN] 转发到 ${toAddr}`);
    return true;
  }
  return run(cmd) !== null;
}

function forwardToCsbotForAnalysis(cli, cfg, toAddr, fromAddr, originalSubject, body, tag) {
  const instructions = [
    `=== 客服分析请求 ===`,
    `此邮件由客服路由脚本自动转发，请分析并将分析报告发送给人工客服。`,
    ``,
    `请按以下步骤处理：`,
    `1. 仔细阅读下方原始客户邮件`,
    `2. 归类（技术问题 / 功能建议 / 账户问题 / 投诉 / 退款 / 其他，可多选）`,
    `3. 评估处理优先级（高 / 中 / 低）及简短理由`,
    `4. 给出 2-3 条具体处理建议`,
    `5. 使用 mail-cli 将分析报告发送到人工客服邮箱：${cfg.mainEmail}`,
    `   - 邮件主题：[客服分析] ${originalSubject}`,
    `   - 邮件正文格式：`,
    `       分类：<类别>`,
    `       优先级：<高/中/低>  原因：<简短说明>`,
    `       处理建议：`,
    `         1. <建议1>`,
    `         2. <建议2>`,
    `       原始邮件摘要：<50字以内>`,
    `       原始发件人：${fromAddr}`,
    ``,
    `⚠️ 重要：禁止直接回复原始客户邮件（${fromAddr}），只需将分析报告发给 ${cfg.mainEmail}。`,
    ``,
    `=== 原始客户邮件 ===`,
    `From: ${fromAddr}`,
    `Subject: ${originalSubject}`,
    ``,
    body,
    ``,
    `--- NOTE: 原邮件可能包含附件，请查看 support 收件箱 ---`,
  ].join("\n");
  const escapedBody = instructions.replace(/'/g, "'\\''");
  const subject = `${tag ? tag + " " : ""}[待分析] ${originalSubject}`.replace(/'/g, "'\\''");
  const cmd = `${cli} --profile ${cfg.supportProfile} compose send --to '${toAddr}' --subject '${subject}' --body '${escapedBody}'`;
  if (dryRun) {
    log(`  [DRY-RUN] 转发分析请求到 ${toAddr}`);
    return true;
  }
  return run(cmd) !== null;
}

function markAsRead(cli, cfg, msgId) {
  if (dryRun) {
    log(`  [DRY-RUN] 标记已读 ${msgId}`);
    return;
  }
  run(`${cli} --profile ${cfg.supportProfile} mail mark --ids '${msgId}' --fid ${cfg.fid} --read`);
}

// ========== 清理过期记录（7天）==========

function cleanupProcessed(dataDir) {
  const processedDir = path.join(dataDir, "processed");
  if (!fs.existsSync(processedDir)) return;
  const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
  for (const f of fs.readdirSync(processedDir)) {
    const fp = path.join(processedDir, f);
    try {
      const stat = fs.statSync(fp);
      if (stat.mtimeMs < cutoff) fs.unlinkSync(fp);
    } catch { /* ignore */ }
  }
  // 清理过期的频率计数文件
  for (const f of fs.readdirSync(dataDir)) {
    if (f.startsWith(".reply-count-")) {
      const fp = path.join(dataDir, f);
      try {
        const stat = fs.statSync(fp);
        if (stat.mtimeMs < cutoff) fs.unlinkSync(fp);
      } catch { /* ignore */ }
    }
  }
}

// ========== 主流程 ==========

function main() {
  const cfg = loadRouterConfig();
  const cli = findMailCli();
  const processedDir = path.join(cfg.dataDir, "processed");
  ensureDir(processedDir);

  // 互斥锁（跨平台简易版：lockfile）
  const lockFile = path.join(cfg.dataDir, "router.lock");
  try {
    // O_EXCL：如果文件已存在则报错，实现互斥
    const fd = fs.openSync(lockFile, "wx");
    fs.closeSync(fd);
  } catch {
    // 检查 lockfile 是否过期（超过 5 分钟视为残留）
    try {
      const stat = fs.statSync(lockFile);
      if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) {
        fs.unlinkSync(lockFile);
        const fd = fs.openSync(lockFile, "wx");
        fs.closeSync(fd);
      } else {
        log("另一个 router 实例正在运行，跳过");
        process.exit(0);
      }
    } catch {
      log("无法获取锁，跳过");
      process.exit(0);
    }
  }

  // 确保退出时释放锁
  const releaseLock = () => { try { fs.unlinkSync(lockFile); } catch { /* ignore */ } };
  process.on("exit", releaseLock);
  process.on("SIGINT", () => { releaseLock(); process.exit(1); });
  process.on("SIGTERM", () => { releaseLock(); process.exit(1); });

  log("开始轮询...");
  cleanupProcessed(cfg.dataDir);

  // 校验 mainEmail 不应是 agent 邮箱
  if (/@claw\.163\.com\s*$/i.test(cfg.mainEmail)) {
    log("⚠ WARNING: mainEmail 配置为 @claw.163.com 地址（agent 邮箱）。");
    log("  mainEmail 应该是人类可直接查看的邮箱（如 [REDACTED_SECRET]），");
    log("  否则商务合作等需要人工处理的邮件将发到 agent 邮箱而非人类邮箱。");
    log("  请修改 router-config.json 中的 mainEmail 为人类邮箱地址。");
  }

  // 解析 csbot 邮箱地址
  const csbotEmail = resolveCsbotEmail(cfg, cli);
  if (!csbotEmail) {
    log("WARNING: 无法获取 csbot 邮箱地址，Bug/未分类邮件将转发到主邮箱");
  }

  // 搜索未读邮件
  const searchResult = run(`${cli} --profile ${cfg.supportProfile} mail search --fid ${cfg.fid} --unread --json`);
  if (!searchResult) {
    log("搜索未读邮件失败");
    process.exit(1);
  }

  let parsed;
  try {
    parsed = JSON.parse(searchResult);
  } catch {
    log("解析搜索结果失败");
    process.exit(1);
  }

  const messages = parsed.data || parsed;
  if (!Array.isArray(messages) || messages.length === 0) {
    log("无未读邮件");
    process.exit(0);
  }

  log(`发现 ${messages.length} 封未读邮件`);

  const results = []; // 用于 --json 输出

  for (const msg of messages) {
    const msgId = msg.id;
    if (!msgId) continue;

    // 跳过已处理
    const processedFile = path.join(processedDir, String(msgId));
    if (fs.existsSync(processedFile)) continue;

    log(`处理邮件 ID: ${msgId}`);

    // 读取 header
    const headerRaw = run(`${cli} --profile ${cfg.supportProfile} read header --id '${msgId}' --fid ${cfg.fid} --json`);
    const headerParsed = headerRaw ? (() => { try { return JSON.parse(headerRaw); } catch { return {}; } })() : {};
    const headers = headerParsed.data || headerParsed;

    // 读取 body
    const body = run(`${cli} --profile ${cfg.supportProfile} read body --id '${msgId}' --fid ${cfg.fid}`) || "";

    const fromAddr = Array.isArray(headers.from) ? headers.from[0] : (headers.from || headers.From || "unknown");
    const subject = headers.subject || headers.Subject || "(无主题)";

    log(`  From: ${fromAddr} | Subject: ${subject}`);

    // 跳过自动回复邮件
    if (isAutoReply(headers, subject)) {
      log("  跳过：检测到自动回复邮件");
      markAsRead(cli, cfg, msgId);
      fs.writeFileSync(processedFile, "auto-reply-skipped");
      results.push({ id: msgId, from: fromAddr, subject, category: "auto-reply", action: "skipped" });
      continue;
    }

    // 跳过内部发件人（如 csbot 回复、其他 agent 邮箱），--dry-run 模式不过滤以便测试
    if (!dryRun && isInternalSender(fromAddr, cfg)) {
      log("  跳过：内部发件人（agent 邮箱）");
      markAsRead(cli, cfg, msgId);
      fs.writeFileSync(processedFile, "internal-sender-skipped");
      results.push({ id: msgId, from: fromAddr, subject, category: "internal-sender", action: "skipped" });
      continue;
    }

    // 分类
    const rule = classifyEmail(subject, body);
    log(`  分类: ${rule.name}`);

    let action = "";

    switch (rule.action) {
      case "auto_reply": {
        if (checkReplyLimit(cfg, fromAddr)) {
          const replyBody = rule.name === "pricing" ? getPricingReply(cfg) : getCancellationReply(cfg);
          log(`  → 自动回复（${rule.name}）`);
          const sent = sendReply(cli, cfg, fromAddr, subject, replyBody);
          if (sent) {
            action = `auto_reply:${rule.name}`;
          } else {
            log(`  ⚠ 自动回复发送失败（${rule.name}），转发分析请求到 AI 邮箱`);
            if (csbotEmail) {
              forwardToCsbotForAnalysis(cli, cfg, csbotEmail, fromAddr, subject, body, `[${rule.name}-回复失败]`);
            } else {
              forwardEmail(cli, cfg, cfg.mainEmail, fromAddr, subject, body, `[${rule.name}-回复失败]`);
            }
            action = `auto_reply_failed:forward`;
          }
        } else {
          log(`  → 回复频率超限，转发分析请求到 AI 邮箱`);
          if (csbotEmail) {
            forwardToCsbotForAnalysis(cli, cfg, csbotEmail, fromAddr, subject, body, `[${rule.name}-超限]`);
          } else {
            forwardEmail(cli, cfg, cfg.mainEmail, fromAddr, subject, body, `[${rule.name}-超限]`);
          }
          action = `rate_limited:forward`;
        }
        break;
      }
      case "forward_main": {
        log(`  → 转发到主邮箱: ${cfg.mainEmail}`);
        forwardEmail(cli, cfg, cfg.mainEmail, fromAddr, subject, body, "[商务]");
        action = "forward_main";
        break;
      }
      case "forward_csbot": {
        const tag = rule.tag || "[待分类]";
        if (csbotEmail) {
          log(`  → 转发分析请求到 AI 邮箱 ${tag}`);
          forwardToCsbotForAnalysis(cli, cfg, csbotEmail, fromAddr, subject, body, tag);
          action = `forward_csbot:${tag}`;
        } else {
          log(`  → csbot 不可用，直接转发到主邮箱 ${tag}`);
          forwardEmail(cli, cfg, cfg.mainEmail, fromAddr, subject, body, tag);
          action = `forward_main:${tag}`;
        }
        break;
      }
    }

    // 标记已读
    markAsRead(cli, cfg, msgId);
    log("  已标记为已读");

    // 记录已处理
    fs.writeFileSync(processedFile, `${rule.name}:${action}`);
    results.push({ id: msgId, from: fromAddr, subject, category: rule.name, action });
  }

  log("轮询完成");

  if (outputJson) {
    console.log(JSON.stringify({ processed: results.length, results }, null, 2));
  }
}

// ========== --test-classify 模式：直接测试分类逻辑 ==========

if (testClassify) {
  if (!testSubject && !testBody) {
    console.error("Usage: node router.js --test-classify --subject \"...\" --body \"...\"");
    process.exit(1);
  }
  const rule = classifyEmail(testSubject, testBody);
  const result = {
    subject: testSubject,
    body: testBody.slice(0, 200) + (testBody.length > 200 ? "..." : ""),
    category: rule.name,
    action: rule.action,
    tag: rule.tag || null,
  };
  if (outputJson) {
    console.log(JSON.stringify(result, null, 2));
  } else {
    console.log(`分类: ${result.category}`);
    console.log(`动作: ${result.action}`);
    if (result.tag) console.log(`标签: ${result.tag}`);
  }
  process.exit(0);
}

main();


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)