dsh-email 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.16.2 (2026-10-08)
8
+
9
+ - Windows 的 OAuth2 access / refresh token 改用当前用户 DPAPI 加密,旧的有效令牌自动原位迁移;不使用明文备份、命令参数或临时明文文件传输令牌。
10
+ - 加密不可用、文件损坏、未知格式或解锁失败时保留原文件;通过私有临时文件与原子替换保存,POSIX 更新时也修正文件权限为 0600。
11
+ - 复查 [PR #22](https://github.com/STARDUSTLC666/dsh-email/pull/22):原建议仅导入加密函数,未实现存储变更。本次在 TypeScript 源码中完成实际修复。恢复方法及保护范围见 [使用说明](docs/USAGE.md) 和 [验收记录](docs/validation/0.16.2.md)。
12
+
13
+ ## 0.16.1 (2026-10-08)
14
+
15
+ - 修复切换会话工作区后相对附件可能读取启动目录同名文件的问题:发信与草稿工具都按当前会话解析路径,绝对路径和无会话调用保持兼容。
16
+ - 中英文账号页及模型工具说明区分逐次确认、主动启用的可信规则与禁止规则,提供 Full Access 下使用草稿手动核对的入口;修正配置文档中的禁止规则字段名。
17
+
7
18
  ## 0.16.0 (2026-10-05)
8
19
 
9
20
  - 增加每账号收件人禁止地址与禁止域名;禁止规则优先于可信名单,并在所有发信路径的 SMTP 连接前拦截。草稿预览显示阻止原因并保留可编辑草稿。
package/README.en.md CHANGED
@@ -8,14 +8,16 @@ Read, send, search and organize email from DSH with multiple accounts.
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/dsh-email)](https://www.npmjs.com/package/dsh-email) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-email-downloads.svg)](https://www.npmjs.com/package/dsh-email)
10
10
 
11
+ Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-email/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-email/pulls).
12
+
11
13
  ## What it does
12
14
 
13
15
  - Read, send, reply and forward using IMAP and SMTP.
14
16
  - Manage attachments, search results and mailbox organization.
15
- - Configure multiple accounts, Outlook OAuth2 and send approval.
17
+ - Configure multiple accounts, Outlook OAuth2, encrypted Windows login tokens and send approval.
16
18
  - Turn new-mail popups off in settings; the preference survives restarts.
17
19
  - Prepare and edit local drafts, review To/Cc, content and attachment bytes, then confirm sending.
18
- - Manage trusted recipients per account and preview address, group and Cc matches.
20
+ - Manage trusted and blocked recipients per account and preview address, group and Cc matches.
19
21
 
20
22
  ## Install
21
23
 
package/README.md CHANGED
@@ -8,14 +8,16 @@
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/dsh-email)](https://www.npmjs.com/package/dsh-email) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-email-downloads.svg)](https://www.npmjs.com/package/dsh-email)
10
10
 
11
+ 欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-email/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-email/pulls)。
12
+
11
13
  ## 功能
12
14
 
13
15
  - 通过 IMAP / SMTP 收发、回复和转发邮件。
14
16
  - 管理附件、搜索结果与邮箱整理操作。
15
- - 多账号设置,支持 Outlook OAuth2 和发信审批。
17
+ - 多账号设置,支持 Outlook OAuth2、Windows 登录令牌加密和发信审批。
16
18
  - 新邮件弹窗可在设置中关闭,保存后重启仍生效。
17
19
  - 准备和编辑本地草稿,核对发件人、To/Cc、正文与附件预览后再确认发送。
18
- - 按账号管理可信收件人,预览规则如何匹配地址、地址组和抄送。
20
+ - 按账号管理可信与禁止收件人,预览规则如何匹配地址、地址组和抄送。
19
21
 
20
22
  ## 安装
21
23
 
@@ -4,6 +4,17 @@
4
4
 
5
5
  These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
6
6
 
7
+ ## 0.16.2 (2026-10-08)
8
+
9
+ - Encrypt Windows access / refresh tokens with current-user DPAPI and migrate valid legacy tokens in place, without plaintext backups or secrets in process arguments.
10
+ - Preserve corrupt, unsupported or locked stores and fail closed when encryption is unavailable. Save through private atomic replacement; POSIX writes use 0600 files.
11
+ - Replace the import-only suggestion in [PR #22](https://github.com/STARDUSTLC666/dsh-email/pull/22) with an implementation in the TypeScript source. See the usage guide for recovery and protection boundaries.
12
+
13
+ ## 0.16.1 (2026-10-08)
14
+
15
+ - Resolve relative attachments against the current session workspace in both send and draft tools, preserving absolute paths and calls without session context.
16
+ - Clarify saved trust, deny rules and Full Access confirmation behavior in both languages and model-facing descriptions; correct the documented deny-rule field names.
17
+
7
18
  ## 0.16.0 (2026-10-05)
8
19
 
9
20
  - Add per-account blocked addresses and domains. Deny rules override trust and stop every sending path before SMTP connection. Draft previews explain the block and preserve editable drafts.
package/docs/DRAFTS.en.md CHANGED
@@ -7,7 +7,7 @@
7
7
  Ask the assistant to use `email_draft`, or create a draft in Settings → Email → Drafts. The tool only saves a local draft; it does not submit mail. Parameters are `to`, `subject`, `text`, with optional `account`, `cc`, and an `attachments` array of local paths.
8
8
 
9
9
  1. Choose the sender account, edit To/Cc, subject and plain text, then save.
10
- 2. Add attachments. Browser uploads become plugin-owned copies; tool-supplied paths remain references to the original files.
10
+ 2. Add attachments. Browser uploads become plugin-owned copies; tool-supplied paths remain references to the original files. Relative tool paths resolve against the current session workspace, while absolute paths remain exact. Calls without session context use the DSH startup directory.
11
11
  3. Review the actual sender and display name, every expanded To/Cc address, content, attachment names and sizes. Download preview attachments to inspect them.
12
12
  4. Acknowledge the review and confirm sending. Drafts require manual confirmation even when all recipients match trusted rules.
13
13
 
package/docs/DRAFTS.md CHANGED
@@ -7,7 +7,7 @@
7
7
  让助手使用 `email_draft` 准备邮件,或在「设置 → 邮件 → 草稿」新建。这个工具只写本地草稿,不连接 SMTP 发信。参数为 `to`、`subject`、`text`,可选 `account`、`cc` 和本地附件路径数组 `attachments`。
8
8
 
9
9
  1. 选择发件账号,编辑 To、Cc、主题和纯文本正文,然后保存。
10
- 2. 添加附件。网页文件选择器保存本插件的副本;工具提供的路径保留原文件引用。
10
+ 2. 添加附件。网页文件选择器保存本插件的副本;工具提供的路径保留原文件引用。工具中的相对路径按当前会话工作区解析,绝对路径保持原样;没有会话上下文时使用 DSH 启动目录。
11
11
  3. 点「检查发送预览」,核对真实发件地址与显示名、展开后的全部 To/Cc、正文、附件名称和大小。可下载本次预览的附件检查。
12
12
  4. 勾选已核对,再点「确认发送」。收件人规则即使全部匹配,草稿仍需这次手动确认。
13
13
 
package/docs/USAGE.en.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  ## Current improvements
6
6
 
7
- Optionally configure blockedAddresses / blockedDomains per account. Rules cover To and Cc and remain active with sendApproval=off. Domains match exactly, excluding subdomains unless listed. Remove blocked recipients and preview again before sending.
7
+ Relative attachment paths in `email_send` and `email_draft` resolve against the current session workspace, avoiding a same-name file in the DSH startup directory after switching workspaces. Absolute paths remain exact; calls without session context retain startup-directory fallback.
8
+
9
+ Set blocked addresses and domains per account in the Recipient Rules tab; the text configuration fields are `denyAddresses` / `denyDomains`. Rules cover To and Cc and remain active with send approval off. Domains match exactly, excluding subdomains unless listed. Remove blocked recipients and preview again before sending. Trust skips `email_send` plugin confirmation only when every To/Cc address matches and skipping has been explicitly enabled; host permissions still apply.
8
10
 
9
11
  ## Tools
10
12
 
@@ -205,7 +207,7 @@ Microsoft has disabled username+password basic auth for Exchange Online: persona
205
207
  - **Body search**: the server side only searches subject / from / to / cc. Most servers (e.g. QQ) have unreliable IMAP `TEXT` / `HEADER` search, so with no results it falls back to a body scan of the most recent `bodySearchLimit` messages (slower; disable with `bodySearchFallback`).
206
208
  - **Attachments**: inline images aren't downloadable separately yet; a failed attachment match errors instead of downloading the wrong file (safe default).
207
209
  - **Password storage**: the authorization code saved in the settings page is written in plaintext to the local `settings.yaml` (the secret mark only keeps it out of logs / exports / diagnostics; no disk encryption). Don't hand `settings.yaml` to untrusted people.
208
- - **OAuth2 token storage**: access / refresh tokens are stored as plaintext JSON at `$DSH_HOME/data/dsh-email/oauth2-tokens.json` (deliberately kept out of `settings.yaml`, so they are not included in settings exports). The file is written with `mode: 0o600`, but that permission bit only takes effect **at file creation** and is **effectively a no-op on Windows** — do not hand this file to untrusted people. Tokens are bound to the issuing application ID; changing `clientId` requires re-login. Deleting an account cleans up its tokens.
210
+ - **OAuth2 token storage**: `$DSH_HOME/data/dsh-email/oauth2-tokens.json` stays outside settings exports. Starting in 0.16.2, **Windows encrypts it with current-user DPAPI**, without an extra password. The first read of valid legacy credentials migrates them in place without a plaintext backup. Unavailable system PowerShell / DPAPI never falls back to plaintext; damaged, unsupported or locked stores are preserved with recovery guidance. macOS / Linux still use plaintext JSON, atomically replaced through a private `0600` file; this is not disk encryption. DPAPI does not isolate other programs running as the same Windows user. A different device / OS account requires login again, and older plugin versions cannot read the encrypted format. For recovery, safely retain and move aside the old file before logging in again. Do not share token files. Tokens remain bound to their application ID; changing `clientId` requires login again, and account deletion removes only that account's tokens.
209
211
  - **Local edits are undone by `pnpm install`**: if you deploy by editing files inside `node_modules/dsh-email/`, any `pnpm install` restores the registry version (0.10.7, for example). To keep changes long-term, install from a local path or a Git commit instead.
210
212
 
211
213
  ## Development
package/docs/USAGE.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  ## 本次改进
6
6
 
7
- 设置页可选填 blockedAddresses / blockedDomains。禁止规则覆盖 To、Cc,sendApproval=off 时仍生效。域名按完整域名匹配,不自动包含子域名。移除被禁止的收件人并重新预览后才能确认发送。
7
+ `email_send` 和 `email_draft` 的相对附件路径以当前会话工作区为基准,避免切换工作区后误读 DSH 启动目录的同名文件;绝对路径保持原样,没有会话上下文时仍使用启动目录。
8
+
9
+ 在「收件人规则」页按账号设置禁止地址与域名;配置文本的字段为 `denyAddresses` / `denyDomains`。禁止规则覆盖 To、Cc,即使关闭发信确认也生效。域名按完整域名匹配,不自动包含子域名。移除被禁止的收件人并重新预览后才能确认发送;只有全部 To/Cc 匹配且主动开启的可信规则才会跳过 `email_send` 插件确认,宿主权限仍生效。
8
10
 
9
11
  ## 工具一览
10
12
 
@@ -205,7 +207,7 @@ dsh plugin --profile web remove dsh-email
205
207
  - **正文搜索**:服务器端只搜 subject / from / to / cc;多数服务器(如 QQ)的 IMAP `TEXT` / `HEADER` 搜索不可靠,无结果时回退到最近 `bodySearchLimit` 封的正文扫描(较慢,可用 `bodySearchFallback` 关闭)。
206
208
  - **附件**:内嵌图片暂不支持单独下载;附件定位失败会直接报错而不是下载错误文件(安全默认)。
207
209
  - **密码落盘**:设置页保存的授权码以明文写在本机 `settings.yaml`(secret 标记只保证它不进日志 / 导出 / 诊断,不做磁盘加密)。请勿把 `settings.yaml` 交给不信任的人。
208
- - **OAuth2 token 落盘**:access / refresh token 以明文 JSON 存在 `$DSH_HOME/data/dsh-email/oauth2-tokens.json`(刻意不放进 `settings.yaml`,因此不会随设置导出)。写入时带了 `mode: 0o600`,但这个权限位只在**文件创建那一刻**生效,且**在 Windows 上等于无效**——请勿把该文件交给不信任的人。token 与签发它的应用 ID 绑定,换了 `clientId` 需要重新登录;删除账号会清理它的 token。
210
+ - **OAuth2 token 落盘**:文件为 `$DSH_HOME/data/dsh-email/oauth2-tokens.json`,不随设置导出。0.16.2 起,**Windows 使用当前用户的 DPAPI 加密**,无需设置额外密码;首次读取旧的有效登录数据时自动原位迁移,不留下明文备份。系统 PowerShell / DPAPI 不可用时不会回退写明文;损坏、未知格式或无法解锁的文件保留,并提示恢复操作。macOS / Linux 仍使用明文 JSON,写入以权限 `0600` 的临时文件原子替换,不把这等同于磁盘加密。DPAPI 不隔离同一 Windows 用户运行的其他程序;换设备 / 系统账号需要重新登录,旧版插件也无法读取新的加密格式。遇到解锁问题时,先妥善保留并移走旧文件,再重新登录;请勿分享令牌文件。token 与应用 ID 绑定,换 `clientId` 需重新登录;删除账号只清理该账号的 token。
209
211
  - **本地改动会被 `pnpm install` 还原**:如果你是直接改 `node_modules/dsh-email/` 里的文件做本地部署,任何一次 `pnpm install` 都会把它还原成 registry 上的版本(例如 0.10.7);要长期保留请改成从本地路径或 Git 提交安装。
210
212
 
211
213
  ## 开发
@@ -1,5 +1,9 @@
1
1
  # dsh-email 验证记录
2
2
 
3
+ [0.16.2 Windows 令牌加密与迁移验收](validation/0.16.2.md)。
4
+
5
+ [0.16.1 会话附件与发信策略验收](validation/0.16.1.md)。
6
+
3
7
  [0.15.3 通知开关验收](validation/0.15.3.md)。
4
8
 
5
9
  [0.15.2 中英文界面验收](validation-language-2026-10-03.md)。
@@ -0,0 +1,21 @@
1
+ # 0.16.1:会话附件与发信策略说明
2
+
3
+ 日期:2026-10-08。Windows / Node 24.16.0,官方 DSH 0.2.1-alpha.1 的隔离网页版。
4
+
5
+ ## 修复与回归
6
+
7
+ - 在启动目录和会话工作区分别放置同名 `report.txt`,内容不同。`email_send` 实际文件读取和 `email_draft` 的附件预览均取得会话文件内容,启动目录原文件保持不变。
8
+ - 绝对附件路径保持精确;没有会话时保留启动目录回退。三个新增回归通过,既有收件人拒绝、确认、取消、大小限制与草稿快照规则保持。
9
+ - 中英文确认开关不再笼统声称 Full Access 必然拒绝:主动保存且启用的可信规则只跳过 `email_send` 插件确认,宿主权限仍生效;禁止规则始终优先。
10
+
11
+ ## 实际界面操作
12
+
13
+ 通过明确标记的脚本工具选择适配器,在聊天中用相对路径创建草稿;进入「设置 → 邮件 → 草稿」,检查发送预览,下载并核对附件,勾选核对框后向本机合成 SMTP 服务确认发送。服务器仅接收一封邮件,附件内容与当前会话文件一致;已发送草稿不再提供再次发送按钮。
14
+
15
+ 切换官方界面语言后核对英文账号页,确认说明、草稿入口和禁止规则提示均显示英文。1280×720 截图中相关说明可读,浏览器控制台无 error / warn,页面无空白或错误覆盖层。
16
+
17
+ ## 范围
18
+
19
+ 未操作用户原生 Electron 窗口,未使用真实邮箱凭据,未向外部邮箱发送测试邮件。脚本选工具验收不证明模型自主选工具能力;UI 的 SMTP 回执不代表外部收件箱送达。附件仅改变路径基准,不增加文件访问权限或关闭确认。
20
+
21
+ 开放反馈 [Email #19](https://github.com/STARDUSTLC666/dsh-email/issues/19) 的名单控件、匹配预览和显式开关均已具备;仍保留主动启用、域名精确匹配及宿主权限边界,咨询没有被自动关闭。
@@ -0,0 +1,28 @@
1
+ # 0.16.2 · Windows OAuth2 令牌保护
2
+
3
+ 2026-10-08,Windows / Node 24.16.0。PR #22 提到了明文令牌存储问题,但 diff 仅向 `lib/oauth2.js` 增加未使用的加密函数导入,未改源码、读写或迁移;构建还会覆盖这行。此次修复在 TypeScript 源码实现。
4
+
5
+ ## 实现与保护范围
6
+
7
+ - Windows 使用系统 PowerShell 调用 .NET `ProtectedData` / DPAPI,绑定 `CurrentUser`,加密整个有效令牌文档。静态脚本不携带凭据;输入输出通过匿名进程管道,隐藏窗口,超时 10 秒,输出有上限,不打印原始子进程异常。
8
+ - 新文件只包含版本、保护机制与密文,没有 access / refresh token 或账号明文。首次读取有效旧文件时先加密成功,再原位替换;不生成明文备份。缓存只复用字节完全相同的已认证文档,每次仍读取文件,不掩盖密文被修改。
9
+ - 私有临时文件写入、同步后再原子替换。加密不可用、未知格式、截断或解锁失败时不覆盖旧数据,不回退 Windows 明文写入。缺失文件仍表示未登录;格式损坏不再被错误地视为可覆盖的空账号集合。
10
+ - POSIX 仍为明文 JSON,更新通过 0600 新文件替换,从而修正旧文件过宽的权限。没有将文件权限描述为加密。
11
+
12
+ DPAPI 使用 Windows 登录身份提供保护,通常要求同一用户及机器;**同一用户运行的其他程序不在隔离范围内**。跨设备、换系统账号或退回旧插件格式时需要重新登录。与本机 `settings.yaml` 中密码账号授权码的保存方式无关,本次没有修改用户配置、密码或系统权限。
13
+
14
+ 参考:[微软 ProtectedData](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata?view=netframework-4.8)、[CryptProtectData](https://learn.microsoft.com/en-us/windows/desktop/api/Dpapi/nf-dpapi-cryptprotectdata)、[Node 文件权限 API](https://nodejs.org/api/fs.html#fschmodpath-mode-callback)。
15
+
16
+ ## 验证
17
+
18
+ 完整邮件回归 346 项通过;最终存储专项 6 项通过,包括真实 Windows DPAPI 加解密、独立 Node 进程解锁、旧数据迁移保留多账号、删除仅影响指定账号、未知格式 / 损坏 / 截断文件保留、缓存后篡改真实密文仍被拒绝、加密不可用时不生成明文或破坏旧文件。设备码、刷新单次请求、撤销登录、SMTP OAuth2 重试与取消等已有协议回归仍通过。
19
+
20
+ 专项测试只使用 E 盘仓库下的隔离合成令牌,不读取本机账号令牌,不连接微软或实际邮箱,不泄露真实凭据。macOS / Linux 的原子保存与权限路径由 GitHub Linux CI 另行检查;Windows 专属原生用例在 Linux 明确跳过,不把模拟检查算作其他平台原生加密。
21
+
22
+ 隔离官方 Alpha 网页在 1280×720 实际操作通过:首次加载后三个合成 Outlook 账号都保持已登录,磁盘已变为密文;在账号页删除 spare 并确认,剩下 work / personal 的令牌和登录状态不变。人为破坏保护机制标识后,英文设置页显示保留原文件和恢复操作的完整提示,文件 SHA256 没有被页面改写;恢复原密文,再切换回邮件页,两账号重新正常显示。正常页面控制台没有 error / warn;故障步骤产生的拒绝响应属于预期检查。
23
+
24
+ ![删除一个账号后,其余账号仍登录](0.16.2-accounts-en.png)
25
+
26
+ ![英文界面的文件恢复说明](0.16.2-recovery-en.png)
27
+
28
+ 没有 API 密钥或真实凭据进入隔离环境,未调用测试连接或实际邮箱。真实微软账号授权、跨 Windows 用户解锁拒绝及原生 DSH 桌面操作不属于本次验收。
package/lib/client.js CHANGED
@@ -18,6 +18,10 @@ function emailError(error) {
18
18
  if (UI_LANG !== 'en') return message;
19
19
  if (error?.status === 401) return 'Your DSH session expired. Reopen DSH and try again.';
20
20
  if (!/[\u3400-\u9fff]/.test(message)) return message;
21
+ if (/未写入明文令牌/.test(message)) return 'Windows credential encryption is unavailable. Check system PowerShell / DPAPI and retry. No plaintext tokens were written.';
22
+ if (/无法读取或解锁 OAuth2 登录数据/.test(message)) return 'The OAuth login file could not be read or unlocked. Windows encrypted files require the original Windows user. Preserve and move aside oauth2-tokens.json before signing in again on a new device or recovering damaged data. The original file was not overwritten.';
23
+ if (/无法读取 OAuth2 登录数据/.test(message)) return 'The OAuth login file could not be read. Check file access permissions; the original file was not overwritten.';
24
+ if (/OAuth2 登录数据过大/.test(message)) return 'The OAuth login data exceeds the storage limit. The original file was not overwritten.';
21
25
  if (/结果.*未|不要.*重发|不要.*发送|本地保存失败|回执未能保存/.test(message)) return 'The send outcome is uncertain or its receipt could not be saved. Check the mailbox before sending again to avoid duplicate delivery.';
22
26
  if (/已提交|不能再次发送/.test(message)) return 'This draft was submitted or its outcome is uncertain. Check the mailbox before copying it to a new draft.';
23
27
  if (/预览已过期|draft-preview-expired/.test(message + ' ' + (error?.code || ''))) return 'The send review expired or was already used. Review the current draft again.';
@@ -288,10 +292,10 @@ var UI = {
288
292
  "provider.undefinedPresetSuffix": "(未定义预设)",
289
293
 
290
294
  // 全局设置
291
- "global.sendApproval": "发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
295
+ "global.sendApproval": "模型发信前确认(建议保留;可信规则可按账号跳过)",
292
296
  "global.downloadDirLabel": "附件下载目录(默认保存在当前工作区)",
293
297
  "global.downloadDirPlaceholder": "留空使用默认",
294
- "global.hint": "以上设置对所有账号统一生效。",
298
+ "global.hint": "以上设置对所有账号统一生效。需要插件确认的工具发信在 Full Access 下不会弹框;可改用草稿页手动核对并确认。禁止收件人规则始终生效。",
295
299
 
296
300
  // 自动保存状态条
297
301
  "status.saved": "已保存",
@@ -469,10 +473,10 @@ var UI = {
469
473
  "provider.undefinedPresetSuffix": "(undefined preset)",
470
474
 
471
475
  // 全局设置
472
- "global.sendApproval": "Ask for confirmation before sending (strongly recommended; auto-denied under Full Access)",
476
+ "global.sendApproval": "Confirm model-initiated sends (recommended; saved trust can skip per account)",
473
477
  "global.downloadDirLabel": "Attachment download directory (defaults to the current workspace)",
474
478
  "global.downloadDirPlaceholder": "Leave empty for the default",
475
- "global.hint": "These settings apply to every account.",
479
+ "global.hint": "These settings apply to every account. Tool sends that still require plugin confirmation cannot show a dialog under Full Access; use the Drafts tab to review and confirm manually. Blocked-recipient rules always apply.",
476
480
 
477
481
  // 自动保存状态条
478
482
  "status.saved": "Saved",
@@ -0,0 +1,6 @@
1
+ export declare class OAuth2StorageError extends Error {
2
+ }
3
+ export declare function readOAuth2Document(file: string): unknown;
4
+ /** Encrypt before touching the destination, then replace it in one rename. */
5
+ export declare function writeOAuth2Document(file: string, doc: unknown): void;
6
+ export declare function isLegacyOAuth2Document(doc: unknown): boolean;
@@ -0,0 +1,121 @@
1
+ /** Windows DPAPI protects stored OAuth2 credentials with the current user's OS key. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ const MAX_STORE_BYTES = 1024 * 1024;
7
+ const PROTECTION = 'windows-dpapi-current-user';
8
+ const ENTROPY = 'dsh-email/oauth2-tokens/v2';
9
+ const STORAGE_MESSAGE = '无法读取或解锁 OAuth2 登录数据:Windows 加密文件需原 Windows 账号;损坏或换设备时请先保留并移走 oauth2-tokens.json,再重新登录。原文件未被覆盖。';
10
+ const ENCRYPTION_MESSAGE = '无法安全保存 OAuth2 登录数据:Windows 用户加密不可用,请检查系统 PowerShell / DPAPI 后重试。未写入明文令牌。';
11
+ export class OAuth2StorageError extends Error {
12
+ }
13
+ // Cache only one authenticated document. File bytes are checked on every read;
14
+ // callers parse their own copy so deleting an account cannot mutate this cache.
15
+ let cached;
16
+ function dpapi(operation, input) {
17
+ const root = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows';
18
+ const executable = join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
19
+ // This script is static: credentials go through stdin, never argv, an env var,
20
+ // a temporary plaintext file, a profile script or a generated shell command.
21
+ const script = `$ErrorActionPreference='Stop'; try {
22
+ Add-Type -AssemblyName System.Security
23
+ $bytes=[Convert]::FromBase64String([Console]::In.ReadToEnd())
24
+ $entropy=[Text.Encoding]::UTF8.GetBytes('${ENTROPY}')
25
+ $result=[Security.Cryptography.ProtectedData]::${operation}($bytes,$entropy,[Security.Cryptography.DataProtectionScope]::CurrentUser)
26
+ [Console]::Out.Write([Convert]::ToBase64String($result))
27
+ } catch { [Console]::Error.Write('DPAPI failed'); exit 1 }`;
28
+ const result = spawnSync(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
29
+ input: input.toString('base64'), encoding: 'utf8', windowsHide: true,
30
+ timeout: 10000, maxBuffer: MAX_STORE_BYTES * 4,
31
+ });
32
+ const output = result.stdout?.trim() ?? '';
33
+ if (result.error !== undefined || result.status !== 0 || output === '' || !/^[A-Za-z0-9+/]+={0,2}$/.test(output)) {
34
+ throw new OAuth2StorageError(operation === 'Protect' ? ENCRYPTION_MESSAGE : STORAGE_MESSAGE);
35
+ }
36
+ return Buffer.from(output, 'base64');
37
+ }
38
+ function isProtected(doc) {
39
+ return doc !== null && typeof doc === 'object' && !Array.isArray(doc)
40
+ && ((doc.version !== undefined && doc.version !== 1) || 'protection' in doc || 'ciphertext' in doc);
41
+ }
42
+ function parseDocument(raw) {
43
+ if (Buffer.byteLength(raw) > MAX_STORE_BYTES * 2)
44
+ throw new OAuth2StorageError(STORAGE_MESSAGE);
45
+ try {
46
+ return JSON.parse(raw);
47
+ }
48
+ catch {
49
+ throw new OAuth2StorageError(STORAGE_MESSAGE);
50
+ }
51
+ }
52
+ export function readOAuth2Document(file) {
53
+ const raw = readFileSync(file, 'utf8');
54
+ const doc = parseDocument(raw);
55
+ if (!isProtected(doc))
56
+ return doc;
57
+ if (process.platform !== 'win32' || doc.version !== 2 || doc.protection !== PROTECTION
58
+ || typeof doc.ciphertext !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(doc.ciphertext)) {
59
+ throw new OAuth2StorageError(STORAGE_MESSAGE);
60
+ }
61
+ try {
62
+ const plaintext = cached?.file === file && cached.envelope === raw
63
+ ? cached.plaintext : dpapi('Unprotect', Buffer.from(doc.ciphertext, 'base64')).toString('utf8');
64
+ const value = JSON.parse(plaintext);
65
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
66
+ || value.version !== 1)
67
+ throw new Error('Invalid protected document');
68
+ cached = { file, envelope: raw, plaintext };
69
+ return value;
70
+ }
71
+ catch {
72
+ throw new OAuth2StorageError(STORAGE_MESSAGE);
73
+ }
74
+ }
75
+ /** Encrypt before touching the destination, then replace it in one rename. */
76
+ export function writeOAuth2Document(file, doc) {
77
+ // Never replace a locked, foreign-user, unsupported or damaged encrypted
78
+ // store with a new empty store during a login or account deletion.
79
+ let existing;
80
+ try {
81
+ existing = parseDocument(readFileSync(file, 'utf8'));
82
+ }
83
+ catch (error) {
84
+ if (error.code !== 'ENOENT')
85
+ throw error;
86
+ }
87
+ if (isProtected(existing))
88
+ readOAuth2Document(file);
89
+ const plaintext = JSON.stringify(doc, null, 2) + '\n';
90
+ if (Buffer.byteLength(plaintext) > MAX_STORE_BYTES)
91
+ throw new OAuth2StorageError('OAuth2 登录数据过大,原文件未被覆盖。');
92
+ const raw = process.platform === 'win32'
93
+ ? JSON.stringify({ version: 2, protection: PROTECTION, ciphertext: dpapi('Protect', Buffer.from(plaintext)).toString('base64') }, null, 2) + '\n'
94
+ : plaintext;
95
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
96
+ const temp = file + '.' + randomUUID() + '.tmp';
97
+ let fd;
98
+ try {
99
+ fd = openSync(temp, 'wx', 0o600);
100
+ writeFileSync(fd, raw, 'utf8');
101
+ fsyncSync(fd);
102
+ closeSync(fd);
103
+ fd = undefined;
104
+ renameSync(temp, file);
105
+ cached = process.platform === 'win32' ? { file, envelope: raw, plaintext } : undefined;
106
+ }
107
+ finally {
108
+ if (fd !== undefined)
109
+ closeSync(fd);
110
+ try {
111
+ unlinkSync(temp);
112
+ }
113
+ catch (error) {
114
+ if (error.code !== 'ENOENT')
115
+ throw error;
116
+ }
117
+ }
118
+ }
119
+ export function isLegacyOAuth2Document(doc) {
120
+ return !isProtected(doc);
121
+ }
package/lib/oauth2.d.ts CHANGED
@@ -71,9 +71,9 @@ interface OAuthFailure {
71
71
  * normal state, not an error: the user simply has not finished in the browser.
72
72
  */
73
73
  export declare function classifyOAuthFailure(payload: unknown, httpStatus?: number): OAuthFailure;
74
- /** Read the store. A missing or unreadable file is 「no tokens」, never a crash. */
74
+ /** Missing/legacy malformed entries mean no tokens; unreadable or corrupt files fail closed. */
75
75
  export declare function readTokenStore(): OAuth2TokenStore;
76
- /** Persist the store. Owner-only where the platform honours the mode; utf8, no BOM. */
76
+ /** Windows: current-user DPAPI. POSIX: private atomic file. Never write plaintext on Windows. */
77
77
  export declare function writeTokenStore(store: OAuth2TokenStore): void;
78
78
  /** Drop one account's tokens (a dead refresh token, or a mailbox that moved). */
79
79
  export declare function clearTokenFor(name: string): boolean;
package/lib/oauth2.js CHANGED
@@ -18,10 +18,10 @@
18
18
  * fingerprint and are never logged. Every message that leaves this module is
19
19
  * the text the user reads in Chinese, without any token material in it.
20
20
  */
21
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
21
  import { homedir } from 'node:os';
23
- import { dirname, join } from 'node:path';
22
+ import { join } from 'node:path';
24
23
  import { clampInt, OUTLOOK_OAUTH2_CLIENT_ID } from './config.js';
24
+ import { isLegacyOAuth2Document, OAuth2StorageError, readOAuth2Document, writeOAuth2Document } from './oauth2-storage.js';
25
25
  /** The authority that serves both endpoints. `common` accepts any work/school/personal tenant. */
26
26
  export const OAUTH2_TENANT = 'common';
27
27
  export const DEVICE_CODE_URL = 'https://login.microsoftonline.com/' + OAUTH2_TENANT + '/oauth2/v2.0/devicecode';
@@ -143,14 +143,18 @@ export function classifyOAuthFailure(payload, httpStatus = 0) {
143
143
  return { ...base, message: '登录失败:' + detail + (aadsts !== '' ? '(AADSTS' + aadsts + ')' : '') };
144
144
  }
145
145
  // --- token store -------------------------------------------------------------
146
- /** Read the store. A missing or unreadable file is 「no tokens」, never a crash. */
146
+ /** Missing/legacy malformed entries mean no tokens; unreadable or corrupt files fail closed. */
147
147
  export function readTokenStore() {
148
148
  let doc;
149
149
  try {
150
- doc = JSON.parse(readFileSync(oauth2TokenFile(), 'utf8'));
150
+ doc = readOAuth2Document(oauth2TokenFile());
151
151
  }
152
- catch {
153
- return { version: 1, accounts: {} };
152
+ catch (error) {
153
+ if (error instanceof OAuth2StorageError)
154
+ throw new OAuth2Error(error.message);
155
+ if (error.code === 'ENOENT')
156
+ return { version: 1, accounts: {} };
157
+ throw new OAuth2Error('无法读取 OAuth2 登录数据,请检查文件访问权限。原文件未被覆盖。');
154
158
  }
155
159
  if (doc === null || typeof doc !== 'object' || Array.isArray(doc))
156
160
  return { version: 1, accounts: {} };
@@ -163,7 +167,10 @@ export function readTokenStore() {
163
167
  accounts[name] = entry;
164
168
  }
165
169
  }
166
- return { version: 1, accounts };
170
+ const store = { version: 1, accounts };
171
+ if (process.platform === 'win32' && isLegacyOAuth2Document(doc) && Object.keys(accounts).length > 0)
172
+ writeTokenStore(store);
173
+ return store;
167
174
  }
168
175
  /** Keep only well-formed entries: a half-written file must not poison a login. */
169
176
  function tokenEntryOf(value) {
@@ -180,11 +187,16 @@ function tokenEntryOf(value) {
180
187
  expiresAt: typeof doc.expiresAt === 'number' && Number.isFinite(doc.expiresAt) ? doc.expiresAt : 0,
181
188
  };
182
189
  }
183
- /** Persist the store. Owner-only where the platform honours the mode; utf8, no BOM. */
190
+ /** Windows: current-user DPAPI. POSIX: private atomic file. Never write plaintext on Windows. */
184
191
  export function writeTokenStore(store) {
185
- const file = oauth2TokenFile();
186
- mkdirSync(dirname(file), { recursive: true });
187
- writeFileSync(file, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
192
+ try {
193
+ writeOAuth2Document(oauth2TokenFile(), store);
194
+ }
195
+ catch (error) {
196
+ if (error instanceof OAuth2StorageError)
197
+ throw new OAuth2Error(error.message);
198
+ throw error;
199
+ }
188
200
  }
189
201
  /** Drop one account's tokens (a dead refresh token, or a mailbox that moved). */
190
202
  export function clearTokenFor(name) {
@@ -297,8 +297,8 @@ export const descriptions = {
297
297
  "email_read": 'Read one full email message by its uid (from email_list or email_search). Returns the plain-text body (HTML mail is converted; oversized bodies are truncated) plus attachment metadata; use email_attachment to download one.',
298
298
  "email_mark": 'Change an existing message: mark it read/unread, star/unstar it, or move it to another folder. Use after email_list/email_search when the user wants to tidy the mailbox (archive, clear unread, flag important mail). Moving uses the server MOVE/COPY so the uid changes; the new uid is reported when the server provides it.',
299
299
  "email_search": 'Search emails by a keyword. The server first searches sender, recipients and subject; those hits are re-verified against the envelopes and, when none of them really carries the keyword (some servers answer every search with the same uids), recent messages are scanned locally including their body while bodySearchFallback is enabled. offset skips the newest matches for paging; since/until still constrain both paths. Returns the same compact rows as email_list.',
300
- "email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the dsh process cwd). Sending asks the user for approval (recipient, subject and attachment count are shown) unless sendApproval is disabled; in Full Access mode the approval policy never asks, so the send is refused with an explanation instead. Never invent recipients or content without the user\'s instruction.',
301
- "email_reply": 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter. Like email_send, this asks the user for approval before sending. Never invent recipients or content without the user\'s instruction.',
300
+ "email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the current session workspace; process cwd is the fallback without a session). Saved blocked-address/domain rules reject To/Cc recipients even when sendApproval is off. Plugin confirmation is required by default, unless sendApproval is disabled or every To/Cc recipient matches saved trust rules with skipApproval enabled. Host permissions still apply. Full Access refuses a send that still requires plugin confirmation; use email_draft for human review and explicit sending. Never invent recipients or content without the user\'s instruction.',
301
+ "email_reply": 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter. Plugin confirmation is required unless sendApproval is disabled; trusted-recipient skipping only applies to email_send. Blocked recipients still stop delivery before SMTP and host permissions remain in effect. Never invent recipients or content without the user\'s instruction.',
302
302
  "email_folders": 'List the mailbox folders of an account (INBOX, Sent, Trash, custom folders, ...). Use the returned path values as the folder argument of the other email tools.',
303
303
  "email_health": 'Self-check for dsh-email: summarizes configured accounts (provider / IMAP / SMTP hosts) without any network connection and never shows passwords. Run this first when troubleshooting.',
304
304
  "email_attachment": 'Download one attachment of a message to a local file (size capped by maxAttachmentBytes). The index matches the attachments array of email_read. Returns the absolute path of the written file.',
@@ -340,7 +340,7 @@ export const parameters = {
340
340
  subject: { type: 'string', required: true, description: 'Email subject' },
341
341
  text: { type: 'string', description: 'Plain-text body' },
342
342
  cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
343
- attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the dsh process cwd)' },
343
+ attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the current session workspace; process cwd is the fallback without a session)' },
344
344
  account: { type: 'string', description: ACCOUNT_HINT },
345
345
  }),
346
346
  "email_reply": compileParameters({
package/lib/tools.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolve } from 'node:path';
1
2
  import { clampInt, PROVIDER_PRESETS } from './config.js';
2
3
  import { messageOf } from './mail-client.js';
3
4
  import { NOT_LOGGED_IN_MESSAGE, oauth2StateOf } from './oauth2.js';
@@ -5,16 +6,22 @@ import { descriptions, parameters, MAX_LIMIT, MARK_ACTIONS, REPLY_MODES, executi
5
6
  /** 单次工具调用的 deadline:普通查询 60s,正文扫描/watch 这类整批操作 120s。 */
6
7
  const QUERY_TIMEOUT_MS = 60000;
7
8
  const BATCH_TIMEOUT_MS = 120000;
9
+ function attachmentPaths(value, exec) {
10
+ const context = exec;
11
+ const cwd = context?.agent?.session?.header?.cwd;
12
+ const base = typeof cwd === 'string' && cwd.trim() ? cwd : process.cwd();
13
+ return normalizeAttachmentPaths(value)?.map(path => resolve(base, path));
14
+ }
8
15
  export function buildEmailTools(runtime, drafts) {
9
16
  const { getPool, getEffectiveSettings, watch: watchCore } = runtime;
10
17
  return [
11
18
  ...(drafts ? [{
12
19
  name: 'email_draft',
13
20
  description: '准备可编辑的本地邮件草稿,不投递邮件。用户在设置 → 邮件 → 草稿中编辑、检查附件预览并明确确认后才发送。可在邮箱尚未配置时准备文字草稿;不用于回复原邮件线程。',
14
- parameters: { type: 'object', properties: { account: { type: 'string', description: '发件账号名,省略时使用默认账号' }, to: { type: 'string', description: 'To 地址,支持显示名和地址组' }, cc: { type: 'string' }, subject: { type: 'string' }, text: { type: 'string', description: '纯文本正文' }, attachments: { type: 'array', items: { type: 'string' }, description: '最多 10 个本地附件路径,用户在预览中检查后才提交' } }, required: ['to', 'subject', 'text'], additionalProperties: false },
21
+ parameters: { type: 'object', properties: { account: { type: 'string', description: '发件账号名,省略时使用默认账号' }, to: { type: 'string', description: 'To 地址,支持显示名和地址组' }, cc: { type: 'string' }, subject: { type: 'string' }, text: { type: 'string', description: '纯文本正文' }, attachments: { type: 'array', items: { type: 'string' }, description: '最多 10 个本地附件路径;相对路径以当前会话工作区为基准,用户在预览中检查后才提交' } }, required: ['to', 'subject', 'text'], additionalProperties: false },
15
22
  output: { schema: { type: 'object', properties: { id: { type: 'string' }, state: { type: 'string' }, subject: { type: 'string' } }, required: ['id', 'state', 'subject'] }, render: (_args, value) => [{ type: 'text', text: '本地草稿已保存,邮件未发送。请在设置 → 邮件 → 草稿中打开并检查。\n草稿编号:' + value.id + '\n主题:' + value.subject }] },
16
23
  timeoutMs: QUERY_TIMEOUT_MS,
17
- async execute(args, exec) { const draft = await drafts.create(args, executionSignal(exec)); return { id: draft.id, state: draft.state, subject: draft.subject }; },
24
+ async execute(args, exec) { const input = args; const draft = await drafts.create({ ...input, attachments: attachmentPaths(input.attachments, exec) }, executionSignal(exec)); return { id: draft.id, state: draft.state, subject: draft.subject }; },
18
25
  }] : []),
19
26
  {
20
27
  name: 'email_list',
@@ -110,7 +117,7 @@ export function buildEmailTools(runtime, drafts) {
110
117
  throw new Error('to 不能为空');
111
118
  if (typeof args.subject !== 'string' || args.subject.trim() === '')
112
119
  throw new Error('subject 不能为空');
113
- return await getPool().send(args.account, args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined, normalizeAttachmentPaths(args.attachments), executionSignal(exec));
120
+ return await getPool().send(args.account, args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined, attachmentPaths(args.attachments, exec), executionSignal(exec));
114
121
  }
115
122
  },
116
123
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "在 DSH 中收发、搜索和整理邮件,支持多个邮箱账号。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",