dsh-smart-mail
Verifieddsh-smart-mail · v0.3.0 · MIT · Web UI
Smart mail for DSH: multi-account IMAP/SMTP send & read tools, whitelist-only wake, and same-thread reply continuation that resumes (and interrupts) the original DSH session, restoring archived sessions when needed.
Install
dsh plugin add dsh-smart-mail Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-smart-mail
DSH 本地插件:一站式邮件收发 + 白名单智能唤醒 + 同一邮件线程续接原会话。(v0.2.0,2026-10-26)
- 纯 Node 实现(
node:tls),不需要额外安装任何软件(无 nodemailer/imapflow/Python)。 - 账号密码使用各邮箱的授权码(不是登录密码)。
- 唤醒与续接全部走 DSH 进程内公开服务:
sessionController.create / prompt / cancel / inspect与workspaceRegistry(归档恢复),零外部 CLI、零弹窗、零额外窗口。
功能
1. 多邮箱
内置预设(授权码开箱即用):
| 预设 | IMAP | SMTP |
|---|---|---|
| imap.qq.com:993 | smtp.qq.com:465 | |
| 163 / 126 | imap.163.com / imap.126.com:993 | smtp.*.com:465(SSL,25 需 STARTTLS,本插件不支持) |
| Gmail | imap.gmail.com:993 | smtp.gmail.com:465 |
| Outlook | outlook.office365.com:993 | smtp.office365.com:587 |
| 自定义 | 任意 IMAP/SMTP 服务器 |
账号保存在 ~/.dsh/smart-mail/accounts.json(0600)。
2. 收发工具(给 Agent)
send_email(to, subject, text, cc?, html?, account?, inReplyTo?, threadTag?)- 传
inReplyTo(被回复的 Message-ID)或threadTag时,自动把[dsh-xxxx]写回主题, 并设置In-Reply-To/References/X-DSH-Session,让对方的回复落回同一会话。
- 传
read_emails(account?, daysBack?=3, limit?=10, unreadOnly?)
3. 白名单唤醒(最高优先级)
- Host 内置 IMAP 轮询(默认 120s,可调 ≥30s)。
allowFrom白名单:精确地址[email protected]或域名后缀@x.com;为空 = 永不唤醒。- 白名单发件人的新线程邮件 → 创建全新 DSH 会话并注入处理提示词(无头、无弹窗)。
- 非白名单邮件只做每轮一条汇总记录,绝不唤醒。
4. 同一邮件线程续接原会话
- 发信时记录线程标识:每封外发邮件都有独立
Message-ID,写入threads.json的outgoing[messageId] = tag,并在主题前加[dsh-<tag>]、加X-DSH-Session: <tag>。 - 收信时识别回复归属(顺序即判定优先级,与 secondlgn/dsh-mail 一致):
References/In-Reply-To命中我方Message-ID→ 取到 tag → 取到会话;X-DSH-Session头(客户端若保留);- 主题里的
[dsh-xxxx]标签(容错Re:/回复:、大小写、丢右括号、编码重写)。 只有命中自己的线程表才会续接,未知标签一律当作新线程。
- 续接前打断:目标会话正在运行 → 先
prompt(mode:'steer')(当前轮次被替换); 若 Host 拒绝 steer,则prompt(mode:'queue')后立刻cancel({sessionId})——cancel保留 inbox(keepInbox: true),所以刚排队的回复会立即被拾起, 而不是等被放弃的那一轮跑完。返回记录里的mode/cancelUsed会如实写明走了哪条路。 - 归档会话拉回:
ArchivedSessionGate会拒绝已归档会话的每一次模型步骤。 续接前调用workspaceRegistry.unarchiveSession(id)(Host 的workspace/unarchiveSessionRemote 用的就是这一条写链路); 若该版本没有此方法,退到同一套enqueueOperation + requireState + setState把 id 从archivedSessionIds里摘掉。实际走哪条路记在日志的unarchivePath里。 拉回失败才降级(并明确记error),不会假装成功。
5. 邮件会话模型(与会话页面的模型选择一致)
邮件唤起的会话可以指定模型,选项来自 Host 的 sessionController.modelCatalog()——
和会话页面上的选择器是同一份目录(provider / model / 推理档位)。
默认行为不变:不选任何模型时,会话跟随 DSH 当前默认模型。
模型不可用 → 本次调用回落默认:选择会先对照目录校验(provider 是否可路由、
模型是否还在、推理档位是否还支持)。任一项不满足就用部署默认模型跑这一封,
并在日志的 modelFallback 里写明原因,不会因此漏回邮件。
优先级:线程自己的模型 → 面板/配置的模型 → 部署默认。
不污染全局默认:实现上只用会话级的 model/selection 事件
(resolveCallConfig 校验后 session.append('model/selection', …)),
不调用 selectModel()——后者会把选择一并写进部署默认,那会让邮件唤醒
悄悄改掉你其他对话用的模型。
配置(cordis.patch.yml 或面板):
modelSelection:
provider: deepseek-official
model: deepseek-flash
reasoningEffort: high # 可省略,省略则用该模型的默认档位
单个线程可以覆盖:面板「邮件线程 → 会话映射」里每行有模型下拉,
或 POST /smart-mail/thread-model {tag, selection|null}。
日志里每次唤醒都会记 model(实际使用的)、modelSource
(thread / config / default)和 modelFallback(降级原因,如有)。
6. 新建会话未指定模型时
不写死模型(不记录 model/selection),请求装配回落到
agentDefaultModel(DSH 当前默认模型)。续接会话沿用该会话自己的模型。
7. 正文识别(MIME)
BODY[TEXT] 由 mime.mjs 解析,而不是"猜一次 base64":
- multipart 拆包:按 boundary 切开,每个部件用它自己的
Content-Transfer-Encoding(base64 / quoted-printable / 7bit)解码; - 优先 text/plain,没有才用 text/html(并剥掉
<style>/<script>/标签); - boundary 推断:QQ 返回的正文没有顶层
Content-Type,只给部件列表, 所以会从----=_NextPart_…这样的分隔行推断 boundary; - 开头泄漏的裸 CSS 规则块会被跳过,让预览从正文开始。
此前版本在 multipart 邮件上把整段正文当成 base64 解码,结果正文为空, 面板显示"无正文"、模型也拿不到内容。
test/mime.test.mjs钉住了这些形状。
活动日志
文件:~/.dsh/smart-mail/activity.log.jsonl(每行一条 JSON,首字段 at)。
轮转文件:~/.dsh/smart-mail/activity-<YYYYMMDD-HHmmss>.jsonl。
事件类型
| kind | 何时写 | 内容 |
|---|---|---|
plugin-start |
每次启动,1 条 | 版本、pid、配置摘要(allowFrom 数量/前 3 条、轮询间隔、提示词长度等),configChanged |
config-change |
仅当有效配置实际变化(首次运行也算) | changedFields、before、after(完整 config) |
mail-seen |
每轮轮询最多 1 条,且仅当本轮有被忽略的邮件 | fresh/blocked/skipped、reasons、senders(前 5)、decisions(每个发件人的 allowlisted 判定)、sample(前 3 封) |
mail-wake |
每封被处理的白名单邮件 1 条,成功/失败都写 | 完整邮件头(from/to/cc/subject/Message-ID/In-Reply-To/References 计数/uid)、threadVia、tag、route、sessionId、mode、interrupted、cancelUsed、unarchivePath;失败另带完整 error |
send |
每次发信 | messageId、tag、inReplyTo、sessionId、成功/失败 |
poll-error |
IMAP 轮询异常 | 账号 + 完整 error |
log-rotate |
每次切分 | 切分原因、归档文件名、旧文件大小、当前策略 |
轮转策略(面板「日志轮转」区与 /smart-mail/status 的 logHealth 均可查)
- 触发:当前文件 ≥
logMaxBytes(默认 2 MiB),或 跨天(mtime 与今天的日历日不同)。 - 保留:归档按
logKeepDays(默认 7 天) 与logKeepFiles(默认 20 个) 双上限裁剪。 - 切分发生在写入之前,单条超大记录也不会让文件无限增长。
- 读取(面板/接口)会跨当前文件 + 归档取最新 N 条,切分后历史仍可查。
- 配置项:
logMaxBytes/logKeepDays/logKeepFiles(cordis.patch.yml 或面板)。
面板
设置 → 插件 → 「智能邮箱」: 账号增删改 + IMAP 测试、白名单编辑、轮询间隔、唤醒提示词/工作目录、手动轮询、 邮件线程续接开关(续接同一会话 / 同线程打断 / 归档会话拉回)、 日志轮转参数与健康度、活动日志、线程→会话映射表。
提示词形态
新线程:
[dsh-smart-mail] 新邮件(已为该线程创建新会话)
邮箱账号:<label>(<address>)
发件人:<from> 主题:<subject> 线程标识:<tag> Message-ID:<...>
时间:<locale time>
正文:
<body>
<wakePrompt>
如需回信,直接调用 send_email(带上 inReplyTo 或 threadTag 以保持同一线程)。
同线程续接:首行改为 同一邮件线程的续接(第 N 轮),并在正文后追加
这是同一邮件线程的后续邮件,请沿用该会话已有的上下文继续处理,不要从头重述。
安装
从插件市场安装(Plugins → Discover),或把本目录作为本地 bundle 安装:
plugin_manager install_bundle target=dsh-smart-mail
开发时也可以让 profile 直接用 link: 指向本目录,改完重启 DSH 即生效。
维护
- 排错先看
mail-wake(ok:false带完整error与邮件头),再看poll-error。 - 线程表在
~/.dsh/smart-mail/threads.json;/smart-mail/threads可查最近 50 条。 - 去重靠
messages.json(按 Message-ID)与seen-uids.json(按账号 UID),重启后仍有效。