dsh-mail-notify
No description
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 30, 2026
- Updated
- Oct 4, 2026
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 个用例):
- 没启用就不碰任何东西。
enabled: false且reply.enabled: false(出厂默认)时,apply在注册session/event监听器和定时器之前就返回,不响应任何会话事件,也没有网络或文件动作。 - 配置写错也不抛回加载器。 缺
to、地址非法、发件域名认不出、reply字段类型错、config 整个是undefined,这些情况都只写error日志然后保持惰性。 - 发信与收信是两个独立开关。
enabled只管发通知,reply.enabled只管收信触发; 可以只发、只收,或都开。 - 引导工具注册失败不影响发信。 它包在单独的
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。
授权码
不写进任何配置文件。按 内联 → 环境变量 → 文件 的顺序找:
config.authCode(不推荐)config.authCodeEnv指的环境变量,默认QQ_SMTP_AUTH_CODEconfig.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 任务完成|<这一轮的摘要>(和平时一样)
三条规矩,都是为了「不误触发」:
- 标题必须整条就是配置里的一条短语。默认认三条自然说法:
创建对话、创建新对话、新建对话(写createSubject可以换成别的词,也可以写数组一次认几种,写false关掉这个方案)。创建对话:改个 bug、我想创建对话都不算——带尾巴就不是那几条说法,仍会按原来的回复规则判定。 客户端自动加的Re:/回复:前缀会先剥掉,所以「回复一封标题是创建对话的邮件」也认。 - 不要求像回复(它本来就不是回复),也不要求在标题里放
DSH。 - 白名单、非自动回复/退信、每小时上限这三道门照样管用(见下面的表), 所以陌生人发一封标题「创建对话」的邮件不能驱动你的 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,回复时邮件客户端会把它引用回来,所以认领顺序是:
- 正文里的 token(最可靠):回复里出现
[DSH-XXXXXX],就进那个 token 对应的会话——你改了标题也照样认得出。 - 主题精确匹配:没有 token 时,回复哪封通知就进那封通知所属的会话(
Re:/回复:/Fwd:前缀会反复剥掉再比)。 - 退回最近一次通知的会话:主题也对不上时(比如 token 被删掉了),用最近发出过通知的那条会话。
- 都没有就新建:还没发过通知(刚启用、状态文件丢了、或只开了收信没开发信)时,只能新建一个会话。
通知正文长这样(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」等价于把执行能力暴露给邮箱。 所以:
- 出厂默认
reply.enabled: false,要你明确打开; - 白名单默认只有你自己,不要把
reply.from写成空或通配; - 建议保持
requireReply: true; - 想要更严就设
reply.keywordScope: subject(默认就是)并把keyword换成别人猜不到的词; - 「创建对话」方案的门槛是标题整条等于某条短语(默认三条常见说法),想更严就只留一条、
或换成别人猜不到的词,不想要这条路就设
createSubject: false; - 模型挑工作区时只能从你已登记的项目里选,答案按列表严格校验——邮件里写什么都变不出一个新目录;
想连这个判断都收回来,就把
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 服务)。
配置
| 字段 | 默认值 | 说明 |
|---|---|---|
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,标题回到只有前缀加结束原因。
自检与测试
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。