← Back to home@Cangjier

dsh-mail-notify

No description

Stars
0
Language
JavaScript
Created
Sep 30, 2026
Updated
Oct 4, 2026
GitHub repo

Introduction

dsh-mail-notify

DeepSeek Harness 插件,把 agent 的每一轮回答和你的邮箱接起来,双向:

  • 发:agent 结束一轮回答就发一封通知邮件,正文是该轮的回答文本,标题带上这轮回答的摘要。 一个任务常常要连跑好几轮(turn/end 之后还会继续下一轮),所以标题分成两种: 中途那几轮是 DSH 任务进行中|…,整段任务真正跑完(agent 停稳)那一封是 DSH 任务完成|…, 收件箱里一眼看得出哪封才是结束了。详见两种邮件。

  • 收:两种玩法。回复那封通知邮件(主题里带关键字,默认 DSH),插件会把邮件正文作为一条用户消息 追加到那封通知所属的会话里,接着原来的上下文继续干;或者新写一封邮件、标题正好是 创建对话 (默认也认 创建新对话、新建对话), 插件会新建一条会话,并把邮件正文原样作为这条对话的第一句话,工作区则由默认模型看着邮件内容 从你已登记的项目里挑(挑不出来才沿用上一次的目录)。答案都按上面的规则回信给你 (见下文「收信触发」一节)。

  • 零依赖:SMTP 客户端(smtp.mjs)、IMAP 客户端(imap.mjs)、邮件解析(mail.mjs) 和邮件组装(mime.mjs)只用 Node 内置模块,不需要 pnpm add 任何东西。

  • 零构建:纯 ESM .mjs,直接就是运行时代码。

  • 默认全部关闭:装好、重启之后也是纯惰性的——不注册监听器、不连网络、不发信、不收信。

  • 自带配置引导:注册了一个模型可见的 mail_notify 工具,可以直接让 Agent 帮你配好、试发、查为什么没触发。

  • 收件人任意域名:发信服务器只由发件账号决定;收件人写 qq.com、163.com、gmail.com 都一样能收。

让 Agent 帮你配

插件注册了 mail_notify 工具,三个 action:

action作用
status报告当前到底会不会发信,以及收信触发的状态:SMTP 端点、发件账号、收件人、授权码从哪儿读、关键字、白名单、UID 水位
guide给出配置文件的确切路径,以及一段可直接粘贴的 YAML
test立刻真发一封测试邮件(可用 to 临时指定别的收件人),验证网络/TLS/授权码/中文编码
reply_status只说收信触发:IMAP 端点、关键字规则、创建对话短语、工作目录策略、白名单、投递方式、已登记的通知与所属会话、UID 水位、最近一次轮询与投递(含逐封判定结果与原因——被跳过的邮件也列出来,所以「刚才那封为什么没触发」事后也查得到)
reply_check立刻只读检查一次邮箱,逐封打印判定结果、原因,以及会落到哪条会话;不改状态、不投递。问「为什么我回复了却没反应」时用它

所以装好之后你只要说「帮我把邮件通知配好」,或者「为什么没收到邮件」,Agent 就能查到原因。 配置写错时这个工具照样注册——那时它正是把错因讲清楚的唯一入口。

装之前先看:它为什么不会拖垮 dsh

四条硬保证,都有测试兜着(node --test,151 个用例):

  1. 没启用就不碰任何东西。 enabled: false 且 reply.enabled: false(出厂默认)时,apply 在注册 session/event 监听器和定时器之前就返回,不响应任何会话事件,也没有网络或文件动作。
  2. 配置写错也不抛回加载器。 缺 to、地址非法、发件域名认不出、reply 字段类型错、config 整个是 undefined,这些情况都只写 error 日志然后保持惰性。
  3. 发信与收信是两个独立开关。 enabled 只管发通知,reply.enabled 只管收信触发; 可以只发、只收,或都开。
  4. 引导工具注册失败不影响发信。 它包在单独的 try 里——附加能力不能连累主功能。

为什么用 bundle,而不是散装文件

第一版把 .mjs 直接放在 profiles/desktop/plugins/ 下,再往 profile 的 cordis.patch.yml 里 insert 一行,结果运行中的 dsh 卡死,只能重启恢复。 profile 内的相对路径插件要靠 HMR 的运行时 reconcile 去 import 一个新模块; 而 bundle 方式是启动时解析的(dsh.profile.bundles),是 xl 插件已经验证过的路径。 所以现在代码放在 profile 目录之外,走 bundle。

安装(分三步,每步都可回退)

第 1 步:只加依赖,不加 bundle —— 风险为零

在 %USERPROFILE%\.dsh\profiles\desktop\package.json 的 dependencies 里加一行:

"dependencies": {
  "dsh-mail-notify": "link:C:/Users/you/Documents/GitHub/dsh-mail-notify"   // 换成你自己的绝对路径
}
cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm install

这一步不要动 dsh.profile.bundles。没有任何 bundle 或 patch 引用这个包, 加载器就不会 import 它,启动树和现在完全一样。确认 dsh 照常启动,再进第 2 步。

第 2 步:加进 bundles

"dsh": {
  "profile": {
    "bundles": [
      "@deepseek-ai/dsh-base",
      "@deepseek-ai/dsh-web-app",
      "xl",
      "dsh-mail-notify"                                                         // 新增
    ]
  }
}

完全退出再启动 dsh(bundle 解析变更本来就要重启,这是 dsh 的设计)。 此时插件已挂载,但 cordis.patch.yml 里 enabled: false,所以仍然不发信。 可以先用 mail_notify 的 guide / test 把配置弄好再来这一步之后的启用。

第 3 步:填个人配置并启用

本包的 cordis.patch.yml 里只有占位地址(you@example.com),真实邮箱写在 profile 自己的覆盖层 %USERPROFILE%\.dsh\profiles\desktop\cordis.patch.yml 里, 这样公开仓库不会留下你的地址:

- id: mail-notify
  name: 'dsh-mail-notify'
  config:
    enabled: true
    smtp:
      user: 你的账号@qq.com
      from: 你的账号@qq.com
    to:
      - 收件人@qq.com

patch 是整体替换 config,不是深合并,所以这段会覆盖本包的默认值——从 enabled 开始写全。 host / port / secure 会按发件域名自动推断,不必手写;其余字段都有代码默认值。

example.com 故意不在内置端点表里:如果只把 enabled 改成 true 而不填真实地址, 会得到一条明确的配置错误日志,而不是静默失败。

回退

把 dsh-mail-notify 从 dsh.profile.bundles 里删掉再重启,就回到装之前的状态。 或者只把 enabled 改回 false。

授权码

不写进任何配置文件。按 内联 → 环境变量 → 文件 的顺序找:

  1. config.authCode(不推荐)
  2. config.authCodeEnv 指的环境变量,默认 QQ_SMTP_AUTH_CODE
  3. config.authCodeFile 指的文件,默认就是本目录下的 secret.txt

secret.txt 每次发信都重新读,所以补进去之后不用重启。它已在 .gitignore 里,不要提交。 QQ 邮箱要用「设置 → 账户 → POP3/IMAP/SMTP 服务」里生成的授权码,不是登录密码。

两种邮件:进行中与完成

驱动循环是 while (await turn()):一个任务的很多轮之间不会回到 idle,只有整段跑完 agent.status 才变成 idle。所以判定链是这样的:

某一轮 turn/end  →  先不发,等「安静窗口」(默认 5 秒)
      ├─ 期间收到下一轮 turn/start ⇒ 上一轮只是中途 → 发「DSH 任务进行中|摘要」
      └─ 窗口到期且 agent 已 idle   ⇒ 整段任务结束   → 发「DSH 任务完成|摘要」

于是:

情况邮件
一轮就干完(最常见)只有一封:DSH 任务完成|…(正文带这一轮的回答)
连跑 3 轮3 封:进行中、进行中、完成(都各自带那一轮的回答)
notifyProgress: false只有最后那封 完成,中途不打扰;正文只带最后一轮的回答

「完成」那一封正文开头会写清 本次任务已全部结束(N 轮),所以即使和「进行中」的邮件混在 一个会话里也能一眼确认收尾。两种邮件都带会话 token,回复任意一封都能接回同一条会话。

配置:

字段默认说明
subjectDSH 任务完成全部结束那一封的前缀(非正常结束时自动补后缀)
subjectProgressDSH 任务进行中中途每轮的前缀
notifyProgresstrue中途那些轮是否各发一封;false = 一个任务只发一封
finalQuietSeconds5安静多久算这一波结束;0 = 不等,立刻算结束(等于不管续跑)

安静窗口只影响「哪一封算完成」,不影响内容;把它调大一些可以更稳妥地吸收紧接着的续跑 (例如目标循环)。窗口内进程被关掉的话,这一封会丢——不放心就设 finalQuietSeconds: 0 或直接关掉 notifyProgress 之外的顾虑。

收信触发:回复邮件 → 往会话里追加一条用户消息

打开 reply.enabled 之后,插件每隔一段时间(默认 60 秒)只读查一次你自己的收件箱。看到符合条件的 新邮件,它就把邮件正文作为一条用户消息追加到那封通知所属的会话里——接着原来的上下文继续干, 而不是另起一个什么都不记得的新对话。这一轮的答案再按发信规则回信给你。

通知邮件(标题已带摘要)
   DSH 任务完成|把 index.mjs 的标题摘要改好了
        ↓ 你直接点「回复」,写上新任务
回复邮件(标题自动是 Re: DSH 任务完成|…,本身已经含关键字)
        ↓ 判定通过
同一条会话里多出一条用户消息(上下文还在,侧栏不会多一行)
        ↓ 跑完一轮
回信:DSH 任务完成|<这一轮的摘要>

用法就这么简单:回一封通知邮件就行——通知标题本来就含 DSH,而回复会带上 Re: 前缀, 两个条件自动满足。

另一条路:标题写「创建对话」 = 起一条新对话

有些话不是「接着上一件事说」,而是想从零起一个话题。那就别回复,直接新写一封邮件:

新邮件
   标题:创建对话
   正文:帮我把 README 的错别字改一遍,顺便补上安装步骤
        ↓ 判定通过(不必像回复、标题里也不必带 DSH)
新建一条会话,第一条用户消息就是上面那行正文——原样,不加任何「来自邮件」的表头
        ↓ 跑完一轮
回信:DSH 任务完成|<这一轮的摘要>(和平时一样)

三条规矩,都是为了「不误触发」:

  1. 标题必须整条就是配置里的一条短语。默认认三条自然说法:创建对话、创建新对话、新建对话 (写 createSubject 可以换成别的词,也可以写数组一次认几种,写 false 关掉这个方案)。 创建对话:改个 bug、我想创建对话 都不算——带尾巴就不是那几条说法,仍会按原来的回复规则判定。 客户端自动加的 Re: / 回复: 前缀会先剥掉,所以「回复一封标题是创建对话的邮件」也认。
  2. 不要求像回复(它本来就不是回复),也不要求在标题里放 DSH。
  3. 白名单、非自动回复/退信、每小时上限这三道门照样管用(见下面的表), 所以陌生人发一封标题「创建对话」的邮件不能驱动你的 agent。

投递方式不受 mode 影响:这条路上永远新建会话,哪怕状态里正好登记着同主题的通知 (标题固定是那几条短语,按主题认领只会认错人)。会话标题取正文首行——固定叫「创建对话」 在侧栏里毫无信息量。

提示词就是正文本身:剥掉客户端自动加的引用历史与本插件的 token 行之后原样交给模型。 也就是说,你在邮件正文里怎么写,对话里第一句话就是什么。

⚠️ 别把发信侧的 subject(通知标题前缀)也设成这几条短语之一,还要同时关掉 subjectSummary—— 那样自己发出去的通知标题就会正好撞上它们,收信侧会把它们当成新任务。真要自定义通知标题, 就把 createSubject 换成别的词,或者干脆设成 false。

新会话开在哪个工作区:让模型看着邮件定

「创建对话」这种从零起的会话,最难的不是内容而是在哪个目录里干活——写死一个路径, 多项目就容易放错地方;沿用「上一次那个」,换项目时一定错。所以 reply.cwd 默认是 auto, 按下面三级决定:

邮件里写了某个已登记工作区的路径,或提到了它的项目名
        ↓ 命中(确定性,不花钱、不联网)
用它
        ↓ 没命中
把「邮件 + 已登记工作区列表」交给当前默认模型,让它只回一行 path 或 UNKNOWN
        ↓ 回了一个列表里的 path
用它(会话会归入那个侧栏项目分组)
        ↓ 回 UNKNOWN / 列表外的东西 / 服务不可用 / 超时
沿用最近一次发过通知的会话目录(就是老的 last 行为),再不行才是用户主目录

几条设计上的取舍,都是为了让「模型判断」不至于变成新的不确定性:

  • 确定性优先:邮件里明写了 C:\work\alpha 或者提到了项目名 alpha,就直接用, 一次模型调用都不发。名字匹配要求整块命中(notify 不会在 dsh-mail-notify 里误命中), 命中多个还分不出长短就判定为说不清,交给模型。
  • 模型只能从列表里挑:答案是严格校验过的——只认已登记工作区的 path(或唯一的名字), 列表外的路径、UNKNOWN、胡言乱语一律当作没挑出来。所以幻觉最坏也只是退回 last, 不可能把会话建到一个凭空编出来的目录里。
  • 候选只有一个时不用问了:只登记了一个工作区就直接用它。
  • 拿不到 llm 服务、超时、模型报错都只记日志:投递照常进行,退回 last。挑工作区 永远不该让一封邮件失败。
  • 每次触发最多问一次,提示词里只有主题、正文和一小段工作区列表;输出上限 512 token (推理型的默认模型会先花掉一批推理 token,上限太小会连一行答案都吐不出来)。 到点(reply.timeoutMs,默认 30 秒)就中止这次调用,直接走兜底。

想让某个项目固定收件:cwd: C:/projects/xxx(绝对路径,直接生效,不问模型); 想回到老行为:cwd: last。当前效果随时可以用 mail_notify 的 reply_status 查看—— 「最近一次投递」那一行会写清工作目录,以及它是配置写死的 / 邮件里写的路径 / 项目名 / 只登记了一个工作区 / 模型挑的 / 沿用上一次哪一种。

消息投到哪条会话

通知正文的末尾会附一段会话 token,回复时邮件客户端会把它引用回来,所以认领顺序是:

  1. 正文里的 token(最可靠):回复里出现 [DSH-XXXXXX],就进那个 token 对应的会话——你改了标题也照样认得出。
  2. 主题精确匹配:没有 token 时,回复哪封通知就进那封通知所属的会话(Re:/回复:/Fwd: 前缀会反复剥掉再比)。
  3. 退回最近一次通知的会话:主题也对不上时(比如 token 被删掉了),用最近发出过通知的那条会话。
  4. 都没有就新建:还没发过通知(刚启用、状态文件丢了、或只开了收信没开发信)时,只能新建一个会话。

通知正文长这样(token 单独占一行且只有 ASCII,不会被客户端按列宽折断):

<这一轮的回答>

——
[DSH-4F2C9A]
回复本邮件可以继续这条对话(保留上面这一行即可,改标题也不影响)。

reply.token: false 可以关掉这段页脚,那时只能靠主题/最近一次通知认领。

注意两条规则的分工:关键字(默认 DSH,只认主题)决定要不要动手,token 只决定进哪条会话。 所以你把标题改成完全不含 DSH 的样子,这封回复会被整体忽略——把关键字留在标题里即可(点「回复」时它本来就在)。

之所以不优先用 In-Reply-To 认领:实测 QQ 会把我们生成的 Message-ID 换成自家的 (<tencent_…@qq.com>),回复里的引用串对不上我们发出去的东西。token 与主题才是可靠线索。

配置 reply.mode: new 可以回到「每封回复都开新对话」的老行为。

投递走的是 ctx.sessionController.resolveAgent(sessionId):会话还活着就直接用;已经冷了(例如 dsh 重启过) 就按它持久化的 preset 与当前默认模型 resume 起来再追加。追加失败(会话被删、写锁被别的进程占着) 不会把这封邮件丢掉——退回新建一个会话,并留一条 warn 日志说明原因。

五道门,缺一不可

#条件默认作用
1发件人在白名单里取 to(你自己)陌生人给这个邮箱发信不能驱动你的 agent
2必须「像回复」开有 In-Reply-To/References,或主题以 Re:/回复: 开头
3不是自动回复/退信/邮件列表开看 Auto-Submitted、Precedence、List-Id、MAILER-DAEMON 等
4关键字出现在主题里DSH,不分大小写普通邮件不会被误当成任务
5每小时不超过 N 次6就算前四道全被绕过,也烧不出无限循环

「创建对话」邮件免掉第 2、4 道(它本来就不是回复,标题里也不会有 DSH),改用 「标题必须整条就是配置里的某条短语」(默认三条自然说法)当闸门,第 1、3、5 道照旧。 它同样不会被自己的通知误触发:通知的标题永远不是那几条短语。

第 2 条同时挡住了自我循环:插件自己发出的通知既没有 In-Reply-To,标题也不以 Re: 开头, 所以它永远不会被自己当成任务。这一点是真实邮箱验证过的——INBOX 里那 19 封通知全部被判为 「不像回复」而跳过。(另外,QQ 会把我们生成 Message-ID 换成自家的,所以判断不能依赖追踪自己发过什么, 只能靠「像不像回复」这条形态规则。)

追加的消息长什么样

  • 内容:「主题 / 发件人 / 时间 + 邮件正文」,引用历史会被剥掉(> 引文、在……写道:、 -----原始邮件-----)。
  • 来源标记:source.kind 是 cordis-host-runner(宿主注入),在会话日志里能看出这条不是手打的。
  • 唤醒方式:followup——排队成一个新的轮次。如果那条会话正好在跑,消息排在它后面,不会打断当前轮。
  • 只在新建时才用到的:工作目录(按 reply.cwd 定: 默认 auto,实在挑不出来才是用户主目录)、用户默认 preset 与当前默认模型、会话标题、侧栏项目分组。

「创建对话」那条路上的消息不一样:内容就是邮件正文原样(同样剥掉引用历史与 token 行, 但不加「主题 / 发件人 / 时间」表头——你写的正文就是对话里的第一句话),会话标题取正文首行 (前 60 个字符),其余(source.kind、followup、工作目录、preset、分组)完全相同。

它不做什么

  • 不改你的邮箱:全程 BODY.PEEK 只读拉取,不标已读、不删除、不移动、不回复原始发件人。
  • 不追历史:第一次运行时只记录 UID 水位(UIDNEXT),更早的邮件一律不处理——装好之前收到的 回复不会被翻出来执行。
  • 不同步等待:投递完就返回;答案由既有的通知链路回信,插件不在这里等结果。
  • 失败会重试但有限:投递失败最多重试 3 次(每分钟一次),仍失败就记 error 日志并放弃这封, 水位才继续前进。

安全

「邮件能触发 agent」等价于把执行能力暴露给邮箱。 所以:

  1. 出厂默认 reply.enabled: false,要你明确打开;
  2. 白名单默认只有你自己,不要把 reply.from 写成空或通配;
  3. 建议保持 requireReply: true;
  4. 想要更严就设 reply.keywordScope: subject(默认就是)并把 keyword 换成别人猜不到的词;
  5. 「创建对话」方案的门槛是标题整条等于某条短语(默认三条常见说法),想更严就只留一条、 或换成别人猜不到的词,不想要这条路就设 createSubject: false;
  6. 模型挑工作区时只能从你已登记的项目里选,答案按列表严格校验——邮件里写什么都变不出一个新目录; 想连这个判断都收回来,就把 cwd 写成固定的绝对路径。

配置

- id: mail-notify
  name: 'dsh-mail-notify'
  config:
    enabled: true
    smtp:
      user: 你的账号@qq.com
      from: 你的账号@qq.com
    to:
      - 收件人@qq.com
    reply:
      enabled: true          # 打开收信触发
      mode: append           # append = 追加到通知所属会话;new = 每封都开新对话
      token: true            # 通知正文里附 [DSH-XXXXXX],回复时用来认领会话
      keyword: DSH
      createSubject: 创建对话 # 标题整条等于它 → 新建会话,正文原样即对话内容;也可写数组、false 关闭
      cwd: auto              # 新会话的工作区:auto = 邮件里认 / 让模型挑 / 退回上一次
      # from:                # 留空即取上面的 to
      #   - 你的账号@qq.com

reply.host / port / secure 同样按发件域名自动推断:qq.com / foxmail.com → imap.qq.com:993, 163.com → imap.163.com:993,gmail.com → imap.gmail.com:993,其余常见域名见代码里的 IMAP_PRESETS。 授权码和 SMTP 共用同一个(QQ 的 IMAP/SMTP 服务用同一个授权码,记得在设置里同时开启 IMAP 服务)。

配置

字段默认值说明
enabledfalse出厂就是关的。false 时不注册监听器、不发信
smtp.user必填SMTP 登录账号(发件邮箱)
smtp.from同 user信头里的发件地址
smtp.host按发件域名推断认不出的域名必须手填,否则只记错误日志、保持不发信
smtp.port推断值隐式 TLS 默认 465,STARTTLS 默认 587
smtp.secure推断值true = 465 隐式 TLS,false = EHLO 后 STARTTLS
to必填收件人数组;每人单独投递,互相看不到地址
subjectDSH 任务完成全部结束那一封的标题前缀;非正常结束时自动补后缀,如「(已取消)」「(出错)」
subjectProgressDSH 任务进行中中途每轮的标题前缀
notifyProgresstrue中途那些轮是否各发一封;false = 一个任务只发一封
finalQuietSeconds5安静多久算这一波任务结束(0 = 不等)
subjectSummarytrue标题是否带本轮回答的摘要:「前缀(结束原因)|摘要」
subjectSummaryChars40摘要最多几个字,按字符截断并加省略号
authCode无内联授权码,不推荐
authCodeEnvQQ_SMTP_AUTH_CODE授权码环境变量名
authCodeFile本目录 secret.txt授权码文件路径,相对路径以本目录为基准
skipSubagentSessionstrue跳过子代理会话,避免一次任务刷出一堆邮件
mergeStepsfalsefalse 只发最后一条助手消息;true 拼接整轮所有步骤
maxBodyChars20000正文超长时截断并注明原长度
includeSessionInfofalse正文末尾附会话 id、工作目录、结束原因、时间
dryRunfalsetrue 时只记日志不连服务器、不建会话,用于试配置
timeoutMs30000单步读写超时
reply.enabledfalse收信触发总开关。false 时不建定时器、不连 IMAP
reply.modeappendappend = 追加到通知所属会话;new = 每封回复新建会话
reply.createSubject[创建对话, 创建新对话, 新建对话]「创建对话」方案:标题整条等于其中一条短语时无条件新建会话,邮件正文原样作为对话内容。可写单条字符串、字符串数组(多加几种说法),false = 关掉这条路
reply.tokentrue通知正文末尾附 [DSH-XXXXXX] 会话 token,回复引用它即可精确认领会话
reply.keywordDSH触发关键字,大小写不敏感
reply.keywordScopesubject关键字出现的位置:subject / body / either
reply.requireReplytrue必须像回复(In-Reply-To/References/Re: 主题)
reply.from取 to发件人白名单,只有这些地址能触发
reply.host / port / secure按发件域名推断IMAP 端点;认不出的域名必须手填
reply.mailboxINBOX查哪个邮箱夹
reply.intervalSeconds60轮询间隔
reply.maxPerHour6每小时最多触发几次
reply.cwdauto新建会话时的工作目录:auto = 先认邮件里的路径/项目名,再让默认模型从已登记工作区里挑,挑不到沿用最近一次;last = 一律沿用;或写绝对路径固定用它
reply.maxMessageBytes524288超过这个大小的邮件跳过,不下载正文
reply.maxPromptChars6000提示词上限,超出截断
reply.stateFile本目录 reply-state.jsonUID 水位与去重记录(已 gitignore)
reply.allowInsecurefalse允许明文 IMAP(只有内网自建服务器才该开;默认强制 STARTTLS)

内置端点:qq.com / vip.qq.com / foxmail.com → smtp.qq.com:465; 163.com / 126.com / yeah.net → 各自 :465;sina.com / 139.com / aliyun.com → 各自 :465; gmail.com → smtp.gmail.com:465;outlook.com / hotmail.com / live.com → smtp.office365.com:587 STARTTLS; icloud.com / me.com → smtp.mail.me.com:587 STARTTLS。其他域名请显式写全 smtp.host/port/secure。

标题里的摘要

摘要是本地推断出来的,不调模型、不联网、不额外花钱:跳过代码块,优先取回答里第一个 Markdown 标题,没有标题就取第一行;首选内容短于 8 个字就往后接一行(最多三条候选行), 让「好的」这种标题能自解释;最后按 subjectSummaryChars 个字符截断并加省略号。 Markdown 标记(#、列表符、**、链接、表格竖线)都会被剥掉。

DSH 任务完成|邮件标题已带摘要
DSH 任务完成(出错)|沙箱拒绝了这次写入
DSH 任务完成|路径已改成 C:/tmp

结束原因后缀排在摘要之前,所以出错、被取消这类结果在收件箱里一眼可见。 不想要摘要就把 subjectSummary 设成 false,标题回到只有前缀加结束原因。

自检与测试

node --test          # 151 个用例:惰性保证 + 两种邮件的判定节奏 + 引导工具 + 正文/标题摘要/子代理 + 邮件解析 + 收信触发(含假 IMAP 服务器、token 认领、追加/新建/创建对话三条投递路径、工作区挑选),不联网不发信不建会话
node selftest.mjs    # 真的发一封固定主题的邮件,验证网络、TLS、授权码、中文编码

测试分五层:tests/plugin.test.mjs(插件行为、惰性保证与两种邮件的时序)、tests/turns.test.mjs (调度器的假时钟用例:安静窗口、续跑、多会话隔离)、tests/config.test.mjs + tests/mail.test.mjs (配置校验与 MIME/字符集/引用剥离)、tests/reply.test.mjs + tests/workspace.test.mjs(判定规则、 认领会话、水位、重试、限流,以及工作区的确定性匹配与假 llm 上的挑选)、 tests/imap.test.mjs + tests/reply-delivery.test.mjs(本机假 IMAP 服务器上的协议层与投递端到端)。

排查

  • 什么都没发生:让 Agent 调 mail_notify 的 status。多半是 enabled 还是 false,或 dryRun 是 true。
  • 「配置无效,插件保持不发送状态」:日志后面跟着具体是哪个字段;用 mail_notify 的 guide 拿改法。
  • 「没有读到 SMTP 授权码」:文件为空或路径不对,注意用授权码而不是登录密码。
  • 535 Authentication failed:授权码过期或被重置,重新生成。
  • 「不支持 STARTTLS」:secure: false 且服务器没有 STARTTLS 时会直接中止,而不是明文发授权码;改用 465。

收信触发相关:

  • 回复了却没有反应:先让 Agent 调 mail_notify 的 reply_check,它会逐封打印判定结果和原因 (「不像回复」「主题没有关键字」「发件人不在白名单」「已经处理过了」…),并说明会追加到哪条会话。 九成情况是原因写在那一行里。
  • reply_check 说「本轮只建立了 UID 水位」:那是第一次运行(或服务器换了 UIDVALIDITY)。 更早的邮件一律不处理,下一封新邮件才会被判定——再回一封即可。
  • 回复进错了会话:说明既没认到 token 也没匹配上主题,退回了「最近一次通知的会话」。看 reply_check 输出的「按正文里的 token 认到 / 按主题匹配到 / 未按 token 认到」;想彻底避免就设 reply.mode: new, 或者确认回复里保留了通知正文末尾那段 [DSH-XXXXXX]。
  • 改过标题的回复完全没反应:关键字(默认 DSH)只认主题,是「要不要动手」的硬闸门; token 只决定进哪条会话。标题里留着 DSH 即可(点「回复」时本来就在)。
  • 新会话开错项目了:reply_status 的「最近一次投递」那一行会写工作目录,以及它是 配置写死的 / 邮件里写的路径 / 项目名 / 模型挑的 / 沿用上一次哪一种。想让它固定下来就设 cwd: C:/projects/xxx(绝对路径,直接生效、不问模型)。要是那一行写的是「沿用最近一次」, 说明模型没从候选里挑出来——常见原因是工作区列表为空(侧栏里没有项目)、邮件里线索太少, 或者宿主没加载 llm 服务(日志里会有 让模型挑工作区失败)。
  • 标题写了「创建对话」却没新建会话:先看 reply_status 里「最近一次轮询」的逐封判定——被跳过的邮件 也会列出来,并写明原因(这一条是补上的:以前跳过的邮件不写任何记录、水位又照样推过去,于是只剩 「没效果」三个字可查)。九成是标题不是整条等于某条短语(创建对话:改个 bug、【创建对话】 都不算, 创建新对话 在默认配置里算),再就是发件人不在白名单、或 createSubject 被设成了 false。 判定通过时那一行会写「主题正好是 「创建对话」…——按「创建对话」方案新建会话」。
  • 追加失败、结果开了新会话:日志里有 追加到会话 … 失败,改为新建会话 以及具体原因 (常见是那条会话已被删除,或另一个 dsh 进程占着写锁)。这封邮件不会被丢掉。
  • 「没有读到授权码」(收信方向):IMAP 和 SMTP 共用同一个授权码;另外 QQ 邮箱要在 「设置 → 账户 → POP3/IMAP/SMTP 服务」里把 IMAP 服务也开启,只开 SMTP 是不够的。
  • reply.enabled 开了但日志里没有轮询记录:看第一条日志有没有 收信触发已启用; 没有就是配置没被 dsh 重新加载(profile 层改动需要重启)。
  • 邮件太大被跳过:日志会写「邮件 N 字节,超过上限 M」;调 reply.maxMessageBytes 或改用不带大附件的回复。
  • 同一封邮件被投递了两次:不应该发生——UID 水位加 Message-ID 去重两道挡着; 如果真出现,把 reply-state.json 里的 handled 发出来看,那是判断逻辑的 bug。