Skip to content
dsh.fish
Bundle

dsh-mail-notify

DeepSeek Harness 插件:agent 每结束一轮回答,就发一封通知邮件。零依赖,只用 Node 内置模块。

Source
Cangjier
License
MIT
Updated
Updated 8 days ago

Readme

# 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` 里加一行:

```jsonc
"dependencies": {
  "dsh-mail-notify": "link:C:/Users/you/Documents/GitHub/dsh-mail-notify"   // 换成你自己的绝对路径
}
```

```powershell
cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm install
```

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

### 第 2 步:加进 bundles

```jsonc
"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` 里,
这样公开仓库不会留下你的地址:

```yaml
- 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,回复任意一封都能接回同一条会话。

配置:

| 字段 | 默认 | 说明 |
|---|---|---|
| `subject` | `DSH 任务完成` | **全部结束**那一封的前缀(非正常结束时自动补后缀) |
| `subjectProgress` | `DSH 任务进行中` | **中途每轮**的前缀 |
| `notifyProgress` | `true` | 中途那些轮是否各发一封;`false` = 一个任务只发一封 |
| `finalQuietSeconds` | `5` | 安静多久算这一波结束;`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` 写成固定的绝对路径。

### 配置

```yaml
- 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 服务**)。

## 配置

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

## 自检与测试

```powershell
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。

Install

dsh plugin --profile web add github:Cangjier/dsh-mail-notify

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source