dsh-email 0.13.1 → 0.14.0
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 +2 -0
- package/README.en.md +10 -3
- package/README.md +10 -3
- package/lib/client.js +14 -13
- package/lib/host-config.d.ts +6 -0
- package/lib/host-config.js +30 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +11 -0
- package/lib/legacy-settings.d.ts +4 -0
- package/lib/legacy-settings.js +64 -0
- package/lib/mail-client.d.ts +3 -3
- package/lib/mail-client.js +27 -9
- package/lib/runtime.d.ts +10 -1
- package/lib/runtime.js +32 -4
- package/lib/settings.d.ts +57 -58
- package/lib/web.d.ts +2 -0
- package/lib/web.js +20 -6
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## 中文版
|
|
6
6
|
|
|
7
|
+
- **0.13.2(2026-09-21)**:同名附件优先按真实 MIME 分段编号下载;旧解析元数据按一对一匹配,避免多个序号都取到第一个同名文件。保留正文分段下载和附件索引缓存,新增文件字节级回归,268 项测试通过。
|
|
7
8
|
- **0.13.1(2026-09-19)**:**新增**:内置一份公共客户端注册(`15dcd5aa-…`,贡献者 [gurio-wine](https://github.com/gurio-wine) 在 [PR #13](https://github.com/STARDUSTLC666/dsh-email/pull/13) 注册,并授权本项目内置使用,特此致谢)——Outlook / Exchange Online 的 OAuth2 登录**开箱即用**,不再需要每个用户自己注册应用。设置页卡片会显示当前生效的应用 ID:留空即用内置的社区应用,填入自己的 `clientId` 即覆盖(账号级仍可覆盖顶层简写)。**文档**:README 说明内置应用来自谁、代价是什么、如何换成自己的;并说明 token 与签发它的应用 ID 绑定,将来替换内置注册需要这些账号重新登录一次。测试 262 → 264 项。
|
|
8
9
|
- **0.13.0(2026-09-18)**:**修复**:①长正文截断会把整段正文丢成空(无断点时硬切,保留正文);②`email_watch` / 网页弹窗的游标会永久漏报一次超过 `limit` 条的新邮件(改为最旧优先分批、游标只推进到实际返回那批);③附件索引缓存未绑定 UIDVALIDITY,服务器重编号后可能按旧索引写出错误附件(缓存键并入 uidValidity)。**性能**:10 个工具声明 `timeoutMs`(search/watch 120s,其余 60s);watch/弹窗轮询只 FETCH 本轮要报的条数(此前每轮最多 FETCH 100 封未读信封);读信与正文搜索只下载 text/* 分段,不再把整封(含附件)拉下来。**口径**:搜索回退路径明确标注为扫描口径(「本页 N 条(仅扫描最近 X 封)」,不再冒充全文件夹匹配数)。**行为变化**:`email_read` 的 `attachments` 只列可下载的 `disposition=attachment`(内嵌图片不再列出);回退扫描改为逐封下载文本分段(省流量、往返略增)。测试 237 → 262 项。
|
|
9
10
|
- **0.12.0(2026-09-18)**:**新增发送别名**(`senderName` / `authUser` / `authPassword`):`user` 只作为发件地址与信箱身份,登录名与登录密码可以另填——Gmail / Workspace 的别名发信、以及「登录账号 ≠ From 地址」的 SMTP 中继不再被 `535 Username and Password not accepted` 拒绝,From 也能带显示名。设置页账号卡片新增「发件显示名 / 登录账号 / 登录账号的密码」三栏(与授权码同一套三态:留空保留已存值、清空即删除)。**新增** `email_search` 的 `offset`,命中多于一页时可翻页。**修复与优化**:文件夹 UIDVALIDITY 变化时重建 `email_watch` / 弹窗的增量基线(不再把重编号后的整箱当成新邮件);搜索的命中复核与结果列表合并为一次 FETCH;`email_attachment` 复用 `email_read` 已解析的 MIME 索引,不再把整封邮件(含附件)重下一遍;`email_folders` 结果缓存 60 秒;网页端新邮件弹窗在标签页不可见时暂停轮询、失败指数退避、皮肤快照降频。**工程**:CI 增加「`lib/` 与 `src/` 不允许漂移」和客户端 bundle 语法检查。测试 237 → 252 项。发送别名的方向来自 [@TianLanDaoRen](https://github.com/TianLanDaoRen) 的 [PR #8](https://github.com/STARDUSTLC666/dsh-email/pull/8)(本实现按 0.11.0 之后的代码重写)。
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
|
|
24
25
|
## English
|
|
25
26
|
|
|
27
|
+
- **0.13.2 (2026-09-21)**: download attachments by their real MIME section IDs, fixing duplicate filenames selecting the first file. Legacy parsed metadata matches sections one-to-one. Body-only reads and attachment-index caching remain intact; 268 tests pass, including downloaded-file byte checks.
|
|
26
28
|
- **0.13.0 (2026-09-18)**: **Fixes**: (1) long bodies could be truncated to nothing (now hard-cut with the body preserved); (2) the `email_watch` / popup cursor permanently skipped new mail beyond one `limit` batch (now oldest-first batching, cursor only advances over what was actually returned); (3) the attachment index cache ignored UIDVALIDITY, so a renumbered mailbox could download the wrong attachment (cache key now includes uidValidity). **Performance**: all ten tools declare `timeoutMs` (120s for search/watch, 60s otherwise); watch/popup polls FETCH only the rows they report (previously up to 100 unread envelopes per poll); reads and body search download text/* parts only instead of the whole message with its attachments. **Semantics**: the body-scan fallback now labels itself as such ("this page only, scanning the newest N messages") instead of claiming a folder-wide match count. **Behaviour change**: `email_read` now lists only downloadable `disposition=attachment` entries (inline images are no longer listed); the fallback scan fetches per message (fewer bytes, slightly more round trips). Tests 237 → 262.
|
|
27
29
|
- **0.12.0 (2026-09-18)**: **send-as alias** (`senderName` / `authUser` / `authPassword`): `user` is now only the From address and the mailbox identity, while the login user and password can differ — Gmail / Workspace aliases and SMTP relays where the login is not the From address no longer fail with `535 Username and Password not accepted`, and the From header can carry a display name. The account card gained three fields (same three-state contract as the authorization code: empty keeps the stored value, clearing deletes the key). **New**: `offset` for `email_search`, so results beyond the first page are reachable. **Fixes and optimisations**: a changed folder UIDVALIDITY re-seeds the `email_watch` / popup baseline instead of reporting the renumbered mailbox as new; search verification and the result rows share one FETCH; `email_attachment` reuses the MIME index `email_read` already parsed instead of downloading the whole message again; `email_folders` is cached for 60s; the web new-mail popup pauses while the tab is hidden, backs off on failures and refreshes its skin snapshot less often. **Engineering**: CI now rejects `lib/` drift against `src/` and syntax-checks the hand-written client bundle. Tests 237 → 252. The send-as direction came from [@TianLanDaoRen](https://github.com/TianLanDaoRen)'s [PR #8](https://github.com/STARDUSTLC666/dsh-email/pull/8) (this implementation is a rewrite on top of 0.11.0).
|
|
28
30
|
- **0.11.0 (2026-09-18)**: merge gurio-wine's four settings-page PRs ([#11](https://github.com/STARDUSTLC666/dsh-email/pull/11)–[#14](https://github.com/STARDUSTLC666/dsh-email/pull/14)) with post-review fixes. **Added**: ① visual multi-account card editor (add/edit/delete, rename, set-default, per-account connection test — edits auto-save; no "Save & Apply" button) and server-preset management (`serverPresets`, custom provider endpoints, no credentials); ② OAuth2 device-code login for Outlook / Exchange Online (IMAP and SMTP share one token; automatic refresh; password-auth accounts unaffected); ③ bilingual settings-panel copy that follows the host's Settings → General language in real time; ④ accounts can pin `authKind` (auto / oauth2 / password), giving hybrid or on-premises tenants that still accept app passwords an escape hatch. **Review fixes**: SMTP OAuth2 could never send (nodemailer's `XOAuth2` reads only `accessToken`, never `pass` — confirmed `EAUTH`); saving no longer unconditionally wipes account-level hand-written imap/smtp endpoints (runtime prefers the account's own host; the old behavior silently re-pointed custom-server accounts to presets, and accounts without a provider lost connection info entirely); rename preserves stored auth codes and advanced keys and refuses to overwrite an existing account name; settings routes now enforce Host / Origin / Content-Type same-origin checks (previously any web page could cross-origin-write settings; DNS rebinding could read snapshots containing plaintext auth codes); responses no longer echo the resolved account map (a plaintext-password copy the front end never reads); raw server errors are credential-scrubbed before display (IMAP/SMTP echo rejected auth strings containing access tokens); deleting an account cleans its tokens (uncommitted saves do not); version conflicts auto-rebase instead of retrying with a stale revision. **No third-party OAuth2 app registration is bundled**: OAuth2 accounts must supply their own `clientId` — see "Outlook OAuth2" below. Tests: 81 → 237. **`email_search` fix**: servers like QQ answer any keyword with the same unrelated uid list; hits are now re-verified against the envelopes (subject/from/to/cc) and fall back to the local body scan when none survive, so an impossible keyword no longer "matches" 40 messages ([#15](https://github.com/STARDUSTLC666/dsh-email/issues/15)).
|
package/README.en.md
CHANGED
|
@@ -37,6 +37,9 @@ Example:
|
|
|
37
37
|
|
|
38
38
|
### Changelog
|
|
39
39
|
|
|
40
|
+
- **0.14.0 (2026-09-23)**: supports Harness 0.1.7 settings. Existing mailbox settings migrate automatically and appear as editable cards; edits auto-save and survive refresh and restart. Advanced options and saved passwords are preserved; removing all cards no longer resurrects a hidden legacy account.
|
|
41
|
+
|
|
42
|
+
- **0.13.2 (2026-09-21)**: download attachments by their real MIME section IDs, fixing duplicate filenames selecting the first file. Legacy parsed metadata matches sections one-to-one. Body-only reads and attachment-index caching remain intact; 268 tests pass, including downloaded-file byte checks.
|
|
40
43
|
- **0.13.1 (2026-09-19)**: ships a community public-client registration (thanks [gurio-wine](https://github.com/gurio-wine)), so Outlook / Exchange Online works out of the box; supply your own `clientId` to override it — the card shows which application is in effect. 264 tests.
|
|
41
44
|
- **0.13.0 (2026-09-18)**: fixes for bodies truncated to nothing, `email_watch` skipping new mail, and the attachment cache ignoring UIDVALIDITY; all ten tools declare a timeout; reads and body search download text parts only; the scan fallback labels its own semantics. 262 tests.
|
|
42
45
|
- **0.12.0 (2026-09-18)**: send-as alias (`senderName` / `authUser` / `authPassword`) and `offset` paging for `email_search`; QQ match-everything searches no longer trusted; popup polling pauses while the tab is hidden.
|
|
@@ -44,13 +47,17 @@ Example:
|
|
|
44
47
|
- **0.10.8 and earlier**: see [CHANGELOG.md](CHANGELOG.md).
|
|
45
48
|
## Compatibility
|
|
46
49
|
|
|
50
|
+
The current baseline is official-source Harness **0.1.7-alpha.2** (2026-09-23, with a local `Symbol.for` tool-scheduler fix). All 18 plugins load together. Legacy migration, editing and automatic saving in the real Web UI, refresh, revision conflicts and restart persistence have been checked with isolated fixture accounts. No live mailbox connection or sending was exercised in this round.
|
|
51
|
+
|
|
52
|
+
2026-09-21: the current release package was installed through the official CLI in an isolated profile and co-loaded with the other two most-downloaded plugins on source-built Harness `0.1.6-alpha.2`. All 18 plugin tools registered; calendar/email configuration checks, PPT theme listing and 17-row table generation passed. The host is based on the official alpha.2 release plus the tool-scheduler `Symbol.for` fix (`93badd88`). This run did not connect to live mail or calendar services.
|
|
53
|
+
|
|
47
54
|
Co-load verification was performed on 2026-09-16 with official source builds of Harness `0.1.5-rc.2` and `0.1.6-alpha.1`: all 18 components load alongside ModLens, with passing tool schemas, skill registration and offline read-only calls.
|
|
48
55
|
|
|
49
56
|
**0.11.0 co-load verification (2026-09-18, locally built Harness `0.1.5-rc.2`, `web` profile)**: the plugin mounted without errors; the settings route answered GET with 200 and no `raw` field in the response; a `text/plain` POST was refused with **415**, proving the same-origin guard holds in the real host; under a `application/json` POST the card projection was correct, and an account pinning `authKind: password` reported both `authKindDeclared` and `authKind` as `password`; the panel actually rendered account cards, the eight provider presets with localized labels, the three-way authentication selector, the application (client) ID field with its hint, the missing-ID warning banner (so `--dsw-alias-state-warn-primary` is genuinely defined in the real host) and the Sign in with Microsoft button; the browser console was clean; save worked end to end, and afterwards `accountsYaml` was restored to empty with the original account intact. Offline tests: 231 green. **Still not done**: an end-to-end OAuth2 run against a real Outlook tenant (the device-code flow needs a human to authorize in a browser) and real sending; the `clientId` paths are covered only against a fake authority. Uses the `cordis.patch.yml` + `dsh.bundle.patch` bundle model. Node requirements are 22.19 or later within 22.x, or 24 or later. Live external-service workflows require separate configuration and validation.
|
|
50
57
|
|
|
51
58
|
On 2026-09-10, npm `dsh-email@0.10.6` passed real QQ mailbox folder/list/read/search calls, the settings page's connection test and Save & Apply, and separate SMTP authentication. An empty authorization-code field correctly used `DSH_EMAIL_PASSWORD`. This recheck did not connect to a real mailbox or send, modify or delete mail.
|
|
52
59
|
|
|
53
|
-
Follows the official [plugin packaging and installation requirements](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md): an ESM entry point, prebuilt `lib/`, `dsh.bundle.patch` and a `cordis.patch.yml` layer. The plugin explicitly injects its required services and supplies JSON Schema parameters, canonical output and rendering,
|
|
60
|
+
Follows the official [plugin packaging and installation requirements](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md): an ESM entry point, prebuilt `lib/`, `dsh.bundle.patch` and a `cordis.patch.yml` layer. The plugin explicitly injects its required services and supplies JSON Schema parameters, canonical output and rendering, without importing host-internal services; configuration uses the public `@deepseek-ai/schemastery` package. Use Node 22.19 or later within 22.x, or Node 24 or later. Harness is evolving rapidly; the version above is the tested baseline.
|
|
54
61
|
|
|
55
62
|
## Installation
|
|
56
63
|
|
|
@@ -58,7 +65,7 @@ Follows the official [plugin packaging and installation requirements](https://gi
|
|
|
58
65
|
dsh plugin --profile web add dsh-email
|
|
59
66
|
```
|
|
60
67
|
|
|
61
|
-
(Or install
|
|
68
|
+
(Or install a specific GitHub commit: `dsh plugin --profile web add github:STARDUSTLC666/dsh-email#<commit>`. The repository includes prebuilt `lib/` artifacts; no `prepare` build is required.)
|
|
62
69
|
|
|
63
70
|
After installing, restart `dsh web`. The plugin ships with an empty config and **won't crash startup**; calling any email tool before configuration returns a clear configuration hint.
|
|
64
71
|
|
|
@@ -71,7 +78,7 @@ The whole settings page follows DSH's light and dark themes: every panel style r
|
|
|
71
78
|
|
|
72
79
|
Multiple accounts can be edited visually in the settings page: account cards add, edit and delete accounts, rename them, pick the default, and run "Test connection" per account name; a half-filled account never blocks saving — it just gets an "incomplete" badge. Card edits are debounced and auto-saved; there is no longer a "write to YAML text, then click save" step. Version conflicts (settings changed elsewhere) are automatically rebased and re-saved once, rather than repeatedly failing with a stale revision. When a card is saved, an already-stored authorization code is kept by default (leave the password field empty to keep it, type into it to overwrite); comments in the YAML are preserved in place where possible — with an explicit notice when they cannot be. Rename re-keys in place, preserving auth codes and advanced keys, and refuses to overwrite an existing account name. Account-level hand-written imap/smtp endpoints are only cleaned when the **provider actually changes** — runtime resolution prefers the account's own host, so a routine save never silently re-points the connection target.
|
|
73
80
|
|
|
74
|
-
|
|
81
|
+
On Harness 0.1.7, the page saves directly to the current profile’s `tool-email` entry and applies changes live. Installations using the default entry automatically import the retired `dsh-email` section from `settings.yaml` or `settings.yaml.imported` once. Existing profile values win, and the original file is preserved. Older hosts retain their original settings storage. Authorization-code fields are marked secret, but saving a filled field still writes its value to the local settings file. For a single account, set `DSH_EMAIL_PASSWORD` and leave the authorization-code field empty to avoid saving it; the environment value is not copied into settings.
|
|
75
82
|
|
|
76
83
|
## Uninstall
|
|
77
84
|
|
package/README.md
CHANGED
|
@@ -45,6 +45,9 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
|
|
|
45
45
|
|
|
46
46
|
### 版本记录
|
|
47
47
|
|
|
48
|
+
- **0.14.0(2026-09-23)**:适配 Harness 0.1.7 设置接口;自动导入旧版邮箱设置,旧账号直接显示为可编辑卡片。编辑即时保存,刷新后保留;保留高级参数与已有密码,清空账号后不再重新出现。
|
|
49
|
+
|
|
50
|
+
- **0.13.2(2026-09-21)**:同名附件优先按真实 MIME 分段编号下载;旧解析元数据按一对一匹配,避免多个序号都取到第一个同名文件。保留正文分段下载和附件索引缓存,新增文件字节级回归,268 项测试通过。
|
|
48
51
|
- **0.13.1(2026-09-19)**:内置一份社区公共客户端注册(感谢 [gurio-wine](https://github.com/gurio-wine)),Outlook / Exchange Online 的 OAuth2 登录开箱即用;想用自己的应用仍可填 `clientId` 覆盖,设置页会显示当前生效的是哪个应用。测试 264 项。
|
|
49
52
|
- **0.13.0(2026-09-18)**:修复长正文截断成空、`email_watch` 永久漏报新邮件、附件缓存跨 UIDVALIDITY 失效;10 个工具声明超时;读信/搜索只下正文分段;搜索回退标明扫描口径。测试 262 项。
|
|
50
53
|
- **0.12.0(2026-09-18)**:新增发送别名(`senderName` / `authUser` / `authPassword`)与 `email_search` 的 `offset` 翻页;修复 QQ 搜索假命中;弹窗轮询按页面可见性节流。
|
|
@@ -52,13 +55,17 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
|
|
|
52
55
|
- **0.10.8 及更早**:见 [CHANGELOG.md](CHANGELOG.md)。
|
|
53
56
|
## 兼容性
|
|
54
57
|
|
|
58
|
+
当前验证基线为官方源码构建的 Harness **0.1.7-alpha.2**(2026-09-23,含本地工具调度器 `Symbol.for` 修复)。18 个插件共同加载;邮件设置已通过旧配置迁移、真实网页编辑与自动保存、刷新恢复、修订冲突和重启持久化检查。本轮使用隔离测试账号,未连接真实邮箱或发送邮件。
|
|
59
|
+
|
|
60
|
+
2026-09-21:当前发布包经官方 CLI 安装到隔离 profile,在源码构建的 Harness `0.1.6-alpha.2` 上与另外两个下载量前三插件共同加载,18 个插件工具注册正常;日历/邮件配置自检、PPT 主题查询和 17 行表格生成通过。测试本体基于官方 alpha.2 发布提交,另含工具调度器 `Symbol.for` 修复(`93badd88`)。本轮未连接真实邮箱或日历服务。
|
|
61
|
+
|
|
55
62
|
2026-09-16 曾在官方源码构建的 Harness `0.1.5-rc.2` 和 `0.1.6-alpha.1` 上完成同载验证:18 个组件与 ModLens 同载,工具 schema、技能注册及离线只读调用检查通过。
|
|
56
63
|
|
|
57
64
|
**0.11.0 的同载验证(2026-09-18,本地构建的 Harness `0.1.5-rc.2`,`web` profile)**:插件挂载无报错;设置路由 GET 返回 200 且响应中已无 `raw` 字段;用 `text/plain` 发 POST 被 **415** 拒绝(同源守卫在真实宿主下生效);`application/json` 的 POST 下卡片投影正确,账号钉住 `authKind: password` 后 `authKindDeclared` 与 `authKind` 均为 `password`;设置面板实际渲染出账号卡片、8 个服务商预设的中文下拉、「认证方式」三态选择器、「应用(客户端)ID」输入格与提示、未填 ID 时的警示条(说明 `--dsw-alias-state-warn-primary` 在真实宿主下确有定义)与「登录 Microsoft 账号」按钮;浏览器控制台无报错;save 全链路可用,验证结束后已把 `accountsYaml` 还原为空、原有账号恢复。离线测试 231 项全绿。**仍未做**:真实 Outlook 租户的 OAuth2 端到端(设备码流程要真人在浏览器完成授权)与真实发信未测,`clientId` 相关路径目前只有假 authority 的用例覆盖。采用 `cordis.patch.yml` + `dsh.bundle.patch` 组合包模型。Node 要求为 22.19 及以上的 22.x,或 24 及以上。外部服务的实际业务操作需按各组件配置单独验证。
|
|
58
65
|
|
|
59
66
|
2026-09-10,npm `dsh-email@0.10.6` 曾通过真实 QQ 邮箱目录、列表、读取和搜索,以及设置页“测试连接”“保存并应用”检查;授权码留空时能继续使用 `DSH_EMAIL_PASSWORD`。独立 SMTP 登录认证也已通过。此次复验未连接真实邮箱,未发送、修改或删除邮件。
|
|
60
67
|
|
|
61
|
-
遵循官方[插件打包与安装要求](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md):ESM 入口、预构建 `lib/`、`dsh.bundle.patch` 和 `cordis.patch.yml` 配置层;显式注入所需服务,提供 JSON Schema
|
|
68
|
+
遵循官方[插件打包与安装要求](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md):ESM 入口、预构建 `lib/`、`dsh.bundle.patch` 和 `cordis.patch.yml` 配置层;显式注入所需服务,提供 JSON Schema 参数、规范化输出和渲染函数,运行时不导入宿主内部服务;配置声明使用官方 `@deepseek-ai/schemastery` 公共包。使用 Node 22.19 及以上的 22.x 或 Node 24 及以上版本;Harness 仍在快速迭代,上述版本是实测基线。
|
|
62
69
|
|
|
63
70
|
## 安装
|
|
64
71
|
|
|
@@ -66,7 +73,7 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
|
|
|
66
73
|
dsh plugin --profile web add dsh-email
|
|
67
74
|
```
|
|
68
75
|
|
|
69
|
-
(或从 GitHub
|
|
76
|
+
(或从 GitHub 安装指定提交:`dsh plugin --profile web add github:STARDUSTLC666/dsh-email#<commit>`。仓库已包含 `lib/` 构建产物,无需 `prepare` 构建。)
|
|
70
77
|
|
|
71
78
|
装好后重启 `dsh web`。插件自带空配置,**不会弄崩启动**;配置前调用任何 email 工具都会返回明确的配置提示。
|
|
72
79
|
|
|
@@ -79,7 +86,7 @@ dsh plugin --profile web add dsh-email
|
|
|
79
86
|
|
|
80
87
|
多账号可以在设置页可视化编辑:账号卡片支持增删改账号、改名、设默认、按账号名单独「测试连接」;没填完的账号不阻断保存,只标一个「未完成」。卡片改动即时防抖落盘,不再有"先写 YAML 文本、再点一次保存"这一步;版本冲突(别处也改了设置)会自动重基后重存一次,而不是拿旧版本号反复失败。保存卡片时,已存的授权码默认保持(密码栏留空 = 不变,填内容 = 覆盖);YAML 里的注释尽量原地保留,实在保不住时会明确提示。改名走的是原地改键,授权码与高级键一并保留,且不允许改成已有账号名(那会顶掉另一个账号)。账号自己手写的 imap/smtp 端点只在**服务商真的换了**时才清洗——运行时以账号自己的 host 优先,普通保存不会悄悄改动连接目标。
|
|
81
88
|
|
|
82
|
-
|
|
89
|
+
Harness 0.1.7 中,设置页直接保存到当前 profile 的 `tool-email` 配置行,修改立即生效。使用默认插件行的旧安装会自动导入 `settings.yaml` 或 `settings.yaml.imported` 中的 `dsh-email` 设置;已有 profile 配置优先,原文件保留且只迁移一次,无需重新输入账号。旧版宿主仍使用原 settings 存储。授权码字段标记为 secret,但填写后保存仍会写入本机配置文件。单账号如需避免保存授权码,可设置 `DSH_EMAIL_PASSWORD` 并将授权码栏留空;环境变量不会被复制进设置文件。
|
|
83
90
|
|
|
84
91
|
## 卸载
|
|
85
92
|
|
package/lib/client.js
CHANGED
|
@@ -10,7 +10,8 @@ const React = require("react");
|
|
|
10
10
|
const { useState, useEffect, useCallback, useRef } = React;
|
|
11
11
|
const h = React.createElement;
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Harness supplies document.baseURI for reverse-proxy mounts and in-page navigation.
|
|
14
|
+
const ROUTE = "_dsh/dsh-email/settings";
|
|
14
15
|
/** 「编辑即保存」的防抖窗口:连续打字只落一次盘。 */
|
|
15
16
|
const SAVE_DEBOUNCE_MS = 800;
|
|
16
17
|
|
|
@@ -245,8 +246,8 @@ var UI = {
|
|
|
245
246
|
// 导航 / 段标题
|
|
246
247
|
"nav.title": "邮件 (dsh-email)",
|
|
247
248
|
"section.title": "邮件",
|
|
248
|
-
"section.intro": "
|
|
249
|
-
"section.readonly": "
|
|
249
|
+
"section.intro": "添加邮箱后,就可以让助手帮你查收、搜索和回复邮件。选择服务商并填写邮箱地址与授权码,修改会自动保存。",
|
|
250
|
+
"section.readonly": "当前配置为只读,可以查看,但无法保存修改。",
|
|
250
251
|
"section.activeAccounts": "当前生效账号:{accounts}",
|
|
251
252
|
|
|
252
253
|
// 内置服务商名(PROVIDERS 里存的就是这些 key)
|
|
@@ -264,7 +265,7 @@ var UI = {
|
|
|
264
265
|
|
|
265
266
|
// 全局设置
|
|
266
267
|
"global.sendApproval": "发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
|
|
267
|
-
"global.downloadDirLabel": "
|
|
268
|
+
"global.downloadDirLabel": "附件下载目录(默认保存在当前工作区)",
|
|
268
269
|
"global.downloadDirPlaceholder": "留空使用默认",
|
|
269
270
|
"global.hint": "以上两项对所有账号统一生效。",
|
|
270
271
|
|
|
@@ -299,9 +300,9 @@ var UI = {
|
|
|
299
300
|
"card.errReservedName": "账号名不能是 defaultAccount(该键保留给默认账号),这份改动先不保存。",
|
|
300
301
|
"card.errNoDefault": "有多个账号还没指定默认账号,这份改动先不保存。",
|
|
301
302
|
"card.yamlParseFailed": "accountsYaml 解析失败:{error}",
|
|
302
|
-
"card.confirmDelete": "删除账号「{name}
|
|
303
|
-
"card.nameLabel": "
|
|
304
|
-
"card.nameHint": "
|
|
303
|
+
"card.confirmDelete": "删除账号「{name}」会同时移除已保存的登录信息,不会删除邮箱里的邮件。",
|
|
304
|
+
"card.nameLabel": "账号名称",
|
|
305
|
+
"card.nameHint": "用来区分多个邮箱,例如「工作邮箱」。修改名称会保留原有设置;名称不能为 defaultAccount。",
|
|
305
306
|
"card.reservedNameWarn": "defaultAccount 是保留键(表示默认账号),请换一个名字。",
|
|
306
307
|
"card.providerLabel": "服务商",
|
|
307
308
|
"card.addressLabel": "邮箱地址",
|
|
@@ -424,7 +425,7 @@ var UI = {
|
|
|
424
425
|
// 导航 / 段标题
|
|
425
426
|
"nav.title": "Email (dsh-email)",
|
|
426
427
|
"section.title": "Email",
|
|
427
|
-
"section.intro": "
|
|
428
|
+
"section.intro": "Add a mailbox so your assistant can read, search and reply to mail. Choose a provider and enter your email address and app password. Changes save automatically.",
|
|
428
429
|
"section.readonly": "The settings store is read-only right now: you can view the settings but not save them.",
|
|
429
430
|
"section.activeAccounts": "Active accounts: {accounts}",
|
|
430
431
|
|
|
@@ -443,7 +444,7 @@ var UI = {
|
|
|
443
444
|
|
|
444
445
|
// 全局设置
|
|
445
446
|
"global.sendApproval": "Ask for confirmation before sending (strongly recommended; auto-denied under Full Access)",
|
|
446
|
-
"global.downloadDirLabel": "Attachment download directory (defaults to
|
|
447
|
+
"global.downloadDirLabel": "Attachment download directory (defaults to the current workspace)",
|
|
447
448
|
"global.downloadDirPlaceholder": "Leave empty for the default",
|
|
448
449
|
"global.hint": "These two settings apply to every account.",
|
|
449
450
|
|
|
@@ -478,9 +479,9 @@ var UI = {
|
|
|
478
479
|
"card.errReservedName": "An account cannot be named defaultAccount (that key is reserved for the default account); this change will not be saved.",
|
|
479
480
|
"card.errNoDefault": "Several accounts exist but no default account was chosen; this change will not be saved.",
|
|
480
481
|
"card.yamlParseFailed": "accountsYaml failed to parse: {error}",
|
|
481
|
-
"card.confirmDelete": "Deleting account “{name}” removes
|
|
482
|
-
"card.nameLabel": "Account name
|
|
483
|
-
"card.nameHint": "
|
|
482
|
+
"card.confirmDelete": "Deleting account “{name}” also removes its saved login details. Messages in the mailbox are not deleted.",
|
|
483
|
+
"card.nameLabel": "Account name",
|
|
484
|
+
"card.nameHint": "Use a name such as “Work” to distinguish your mailboxes. Renaming keeps the existing settings; defaultAccount is reserved.",
|
|
484
485
|
"card.reservedNameWarn": "defaultAccount is a reserved key (it means the default account); pick another name.",
|
|
485
486
|
"card.providerLabel": "Provider",
|
|
486
487
|
"card.addressLabel": "Email address",
|
|
@@ -2513,7 +2514,7 @@ function startWhaleWidget() {
|
|
|
2513
2514
|
const snap = await api();
|
|
2514
2515
|
lastSnapshot = Date.now();
|
|
2515
2516
|
if (snap && snap.whale) {
|
|
2516
|
-
whaleUrl = snap.whale.url || "";
|
|
2517
|
+
whaleUrl = (snap.whale.url || "").replace(/^\/_dsh\//, "_dsh/");
|
|
2517
2518
|
whaleCredit = snap.whale.credit || "";
|
|
2518
2519
|
}
|
|
2519
2520
|
hasAccounts = !!(snap && snap.accounts && snap.accounts.length > 0);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Harness 0.1.7 keeps editable fields in the profile and supplies live references. */
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
import type { EmailConfig } from './config.js';
|
|
4
|
+
export declare const Config: z;
|
|
5
|
+
/** A live view also accepts ordinary values from older hosts and direct callers. */
|
|
6
|
+
export declare function liveConfig(config: object): EmailConfig;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Harness 0.1.7 keeps editable fields in the profile and supplies live references. */
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
const endpoint = () => z.object({
|
|
4
|
+
host: z.string(), port: z.number().min(1).max(65535), secure: z.boolean(),
|
|
5
|
+
connectionTimeoutMs: z.number(), socketTimeoutMs: z.number(),
|
|
6
|
+
});
|
|
7
|
+
export const Config = z.object({
|
|
8
|
+
provider: z.string().volatile(), user: z.string().volatile(),
|
|
9
|
+
password: z.string().role('secret').volatile(),
|
|
10
|
+
senderName: z.string().volatile(), authUser: z.string().volatile(),
|
|
11
|
+
authPassword: z.string().role('secret').volatile(),
|
|
12
|
+
clientId: z.string().volatile(), authKind: z.union(['password', 'oauth2']).volatile(),
|
|
13
|
+
imap: endpoint().volatile(), smtp: endpoint().volatile(),
|
|
14
|
+
inboxFolder: z.string().volatile(), sendApproval: z.boolean().volatile(),
|
|
15
|
+
maxBodyChars: z.number().volatile(), downloadDir: z.string().volatile(),
|
|
16
|
+
accounts: z.dict(z.any()).role('secret').volatile(), defaultAccount: z.string().volatile(),
|
|
17
|
+
accountsYaml: z.string().role('secret').volatile(), serverPresets: z.string().volatile(),
|
|
18
|
+
maxAttachmentBytes: z.number().volatile(), bodySearchFallback: z.boolean().volatile(),
|
|
19
|
+
bodySearchLimit: z.number().volatile(), idleTimeoutMs: z.number().volatile(),
|
|
20
|
+
legacySettingsImported: z.boolean().volatile(),
|
|
21
|
+
});
|
|
22
|
+
/** A live view also accepts ordinary values from older hosts and direct callers. */
|
|
23
|
+
export function liveConfig(config) {
|
|
24
|
+
return new Proxy(config, {
|
|
25
|
+
get(target, key, receiver) {
|
|
26
|
+
const value = Reflect.get(target, key, receiver);
|
|
27
|
+
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -2,10 +2,21 @@ import { installSendApproval } from './approval.js';
|
|
|
2
2
|
import { createEmailRuntime } from './runtime.js';
|
|
3
3
|
import { buildEmailTools } from './tools.js';
|
|
4
4
|
import { EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
|
5
|
+
import { liveConfig, Config as ConfigSchema } from './host-config.js';
|
|
6
|
+
import { installLegacySettingsImport } from './legacy-settings.js';
|
|
7
|
+
import { EmailSettingsSchema, SETTINGS_NAMESPACE } from './settings.js';
|
|
8
|
+
export const Config = ConfigSchema;
|
|
5
9
|
export const name = 'tool-email';
|
|
6
10
|
export const inject = ['settings', 'tools'];
|
|
7
11
|
/** Compose settings/pool lifecycle, tools, browser routes and the outgoing-mail gate. */
|
|
8
12
|
export function apply(ctx, config = {}) {
|
|
13
|
+
config = liveConfig(config);
|
|
14
|
+
installLegacySettingsImport(ctx, config, SETTINGS_NAMESPACE, 'tool-email', Object.keys(EmailSettingsSchema.dict));
|
|
15
|
+
if (typeof ctx.settings.configure === 'function' && typeof ctx.inject === 'function') {
|
|
16
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
17
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
9
20
|
const runtime = createEmailRuntime(ctx, config);
|
|
10
21
|
const backend = new EmailSettingsBackend(ctx, runtime.settingsScope, config);
|
|
11
22
|
backend.watchImpl = runtime.watch;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** No backup is edited or deleted; the completion marker lives beside imported values. */
|
|
2
|
+
export declare function importLegacySettings(ctx: any, config: Record<string, any>, namespace: string, entryId: string, fields: readonly string[], home?: string): Promise<boolean>;
|
|
3
|
+
/** Defer persistence until the entry and settings provider are active. */
|
|
4
|
+
export declare function installLegacySettingsImport(ctx: any, config: Record<string, any>, namespace: string, entryId: string, fields: readonly string[]): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Import only this plugin's retired settings section, preserving profile overrides. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { parse } from 'yaml';
|
|
6
|
+
function plain(value) {
|
|
7
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function missing(legacy, current) {
|
|
10
|
+
const patch = {};
|
|
11
|
+
for (const [key, value] of Object.entries(legacy)) {
|
|
12
|
+
if (current[key] === undefined)
|
|
13
|
+
patch[key] = value;
|
|
14
|
+
else if (plain(value) && plain(current[key])) {
|
|
15
|
+
const nested = missing(value, current[key]);
|
|
16
|
+
if (Object.keys(nested).length)
|
|
17
|
+
patch[key] = nested;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return patch;
|
|
21
|
+
}
|
|
22
|
+
/** No backup is edited or deleted; the completion marker lives beside imported values. */
|
|
23
|
+
export async function importLegacySettings(ctx, config, namespace, entryId, fields, home) {
|
|
24
|
+
if (typeof ctx.settings.register === 'function' || config.legacySettingsImported === true)
|
|
25
|
+
return false;
|
|
26
|
+
// Old namespaces were global. Do not copy credentials into unrelated custom instances.
|
|
27
|
+
if (ctx.fiber?.entry?.options.id !== entryId)
|
|
28
|
+
return false;
|
|
29
|
+
let base = home ?? (process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'));
|
|
30
|
+
if (base === '~')
|
|
31
|
+
base = homedir();
|
|
32
|
+
else if (base.startsWith('~/') || base.startsWith('~\\'))
|
|
33
|
+
base = join(homedir(), base.slice(2));
|
|
34
|
+
let document;
|
|
35
|
+
for (const file of ['settings.yaml.imported', 'settings.yaml']) {
|
|
36
|
+
try {
|
|
37
|
+
document = parse(readFileSync(resolve(base, file), 'utf8'));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== 'ENOENT')
|
|
42
|
+
throw new Error('Cannot read legacy settings; original file preserved');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!plain(document) || !plain(document[namespace]))
|
|
46
|
+
return false;
|
|
47
|
+
const section = Object.fromEntries(fields.filter(key => Object.hasOwn(document[namespace], key)).map(key => [key, document[namespace][key]]));
|
|
48
|
+
if (Object.keys(section).length === 0)
|
|
49
|
+
return false;
|
|
50
|
+
await ctx.settings.update(entryId, { ...missing(section, { ...config }), legacySettingsImported: true });
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/** Defer persistence until the entry and settings provider are active. */
|
|
54
|
+
export function installLegacySettingsImport(ctx, config, namespace, entryId, fields) {
|
|
55
|
+
if (typeof ctx.settings.register === 'function' || typeof ctx.inject !== 'function')
|
|
56
|
+
return;
|
|
57
|
+
ctx.inject(['appReady'], (readyCtx) => {
|
|
58
|
+
readyCtx.effect(() => readyCtx.appReady.onReady(() => {
|
|
59
|
+
void importLegacySettings(ctx, config, namespace, entryId, fields).catch(() => {
|
|
60
|
+
ctx.logger?.warn?.(namespace + ': legacy settings could not be imported; the original settings file is preserved.');
|
|
61
|
+
});
|
|
62
|
+
}));
|
|
63
|
+
});
|
|
64
|
+
}
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -69,9 +69,9 @@ interface AttachmentPart {
|
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
71
|
* Map the index in the mailparser attachment list (what email_read showed the
|
|
72
|
-
* model) onto a bodyStructure part.
|
|
73
|
-
*
|
|
74
|
-
* the
|
|
72
|
+
* model) onto a bodyStructure part. Real IMAP section ids are authoritative.
|
|
73
|
+
* Legacy mailparser indexes need one-to-one name/type/size matching so duplicate
|
|
74
|
+
* filenames cannot repeatedly select the first attachment.
|
|
75
75
|
*/
|
|
76
76
|
export declare function selectAttachmentPart(readAttachments: EmailAttachmentMeta[], parts: AttachmentPart[], index: number): AttachmentPart | undefined;
|
|
77
77
|
/** Case-insensitive match of a query against subject/from/body text. */
|
package/lib/mail-client.js
CHANGED
|
@@ -103,20 +103,38 @@ function collectAttachmentParts(node, out = []) {
|
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Map the index in the mailparser attachment list (what email_read showed the
|
|
106
|
-
* model) onto a bodyStructure part.
|
|
107
|
-
*
|
|
108
|
-
* the
|
|
106
|
+
* model) onto a bodyStructure part. Real IMAP section ids are authoritative.
|
|
107
|
+
* Legacy mailparser indexes need one-to-one name/type/size matching so duplicate
|
|
108
|
+
* filenames cannot repeatedly select the first attachment.
|
|
109
109
|
*/
|
|
110
110
|
export function selectAttachmentPart(readAttachments, parts, index) {
|
|
111
111
|
const meta = readAttachments[index];
|
|
112
112
|
if (meta === undefined)
|
|
113
113
|
return undefined;
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
return
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
const hasSectionId = (item) => /^\d+(?:\.\d+)*$/.test(item.part);
|
|
115
|
+
if (hasSectionId(meta))
|
|
116
|
+
return parts.find(part => part.part === meta.part);
|
|
117
|
+
const used = new Set();
|
|
118
|
+
// Reserve exact identities even if mixed with older parsed metadata.
|
|
119
|
+
for (const item of readAttachments)
|
|
120
|
+
if (hasSectionId(item))
|
|
121
|
+
used.add(item.part);
|
|
122
|
+
for (let position = 0; position <= index; position++) {
|
|
123
|
+
const item = readAttachments[position];
|
|
124
|
+
if (hasSectionId(item))
|
|
125
|
+
continue;
|
|
126
|
+
const available = parts.filter(part => !used.has(part.part));
|
|
127
|
+
const sameName = (part) => part.filename === item.filename || sanitizeFilename(part.filename) === item.filename;
|
|
128
|
+
const tolerance = Math.max(64, Math.ceil(item.size * 0.5));
|
|
129
|
+
const sameTypeAndSize = (part) => part.contentType === item.contentType && Math.abs(part.size - item.size) <= tolerance;
|
|
130
|
+
const match = available.find(part => sameName(part) && sameTypeAndSize(part))
|
|
131
|
+
?? available.find(sameName) ?? available.find(sameTypeAndSize);
|
|
132
|
+
if (position === index)
|
|
133
|
+
return match;
|
|
134
|
+
if (match !== undefined)
|
|
135
|
+
used.add(match.part);
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
120
138
|
}
|
|
121
139
|
/**
|
|
122
140
|
* Leaf text/* parts that can carry the message body, attachment parts excluded.
|
package/lib/runtime.d.ts
CHANGED
|
@@ -6,10 +6,12 @@ import type { EmailWatchResult } from './types.js';
|
|
|
6
6
|
export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
|
|
7
7
|
export interface EmailSettingsScope {
|
|
8
8
|
get(): unknown;
|
|
9
|
+
namespace?: string;
|
|
10
|
+
config?: EmailConfig;
|
|
9
11
|
}
|
|
10
12
|
export interface EmailRuntimeContext {
|
|
11
13
|
settings: {
|
|
12
|
-
register(namespace: string, schema: unknown, options: {
|
|
14
|
+
register?(namespace: string, schema: unknown, options: {
|
|
13
15
|
base: Partial<EmailSettingsValue>;
|
|
14
16
|
applies: 'live';
|
|
15
17
|
validate(value: unknown): void;
|
|
@@ -19,6 +21,13 @@ export interface EmailRuntimeContext {
|
|
|
19
21
|
user?: Partial<EmailSettingsValue>;
|
|
20
22
|
}>;
|
|
21
23
|
};
|
|
24
|
+
fiber?: {
|
|
25
|
+
entry?: {
|
|
26
|
+
options: {
|
|
27
|
+
id: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
22
31
|
effect(effect: () => () => void): unknown;
|
|
23
32
|
logger?: {
|
|
24
33
|
warn?(message: string): void;
|
package/lib/runtime.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Live settings, account-pool ownership, and independent tool/web watch cursors. */
|
|
2
|
-
import { clampInt, presetNamesIn, resolveEmailSettings } from './config.js';
|
|
2
|
+
import { clampInt, presetNamesIn, resolveEmailSettings, serializeAccountsYaml } from './config.js';
|
|
3
3
|
import { EmailPool, messageOf } from './mail-client.js';
|
|
4
4
|
import { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
|
|
5
|
+
import { liveConfig } from './host-config.js';
|
|
5
6
|
function fingerprintSettings(settings) {
|
|
6
7
|
return JSON.stringify({
|
|
7
8
|
accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
|
|
@@ -18,17 +19,44 @@ function fingerprintSettings(settings) {
|
|
|
18
19
|
}
|
|
19
20
|
/** Register live settings and own exactly one pool for their effective value. */
|
|
20
21
|
export function createEmailRuntime(ctx, config, createPool = settings => new EmailPool(settings)) {
|
|
21
|
-
|
|
22
|
+
config = liveConfig(config);
|
|
23
|
+
const legacy = typeof ctx.settings.register === 'function';
|
|
24
|
+
const namespace = legacy ? SETTINGS_NAMESPACE : ctx.fiber?.entry?.options.id ?? 'tool-email';
|
|
25
|
+
const settingsScope = legacy ? ctx.settings.register(SETTINGS_NAMESPACE, EmailSettingsSchema, {
|
|
22
26
|
base: toSettingsBase(config),
|
|
23
27
|
applies: 'live',
|
|
24
28
|
// The provider dropdown offers the custom preset names beside the built-ins,
|
|
25
29
|
// so validation must accept whatever the table in effect defines.
|
|
26
30
|
validate: value => validateSettingsValue(value, presetNamesIn(value?.serverPresets ?? config.serverPresets)),
|
|
27
|
-
})
|
|
31
|
+
}) : {
|
|
32
|
+
namespace,
|
|
33
|
+
config,
|
|
34
|
+
get: () => {
|
|
35
|
+
let endpointDefaults = {};
|
|
36
|
+
try {
|
|
37
|
+
const settings = resolveEmailSettings({ ...config });
|
|
38
|
+
const account = settings.accounts.get(settings.defaultAccount);
|
|
39
|
+
if (account)
|
|
40
|
+
endpointDefaults = { imap: account.imap, smtp: account.smtp };
|
|
41
|
+
}
|
|
42
|
+
catch { /* A fresh installation remains editable before credentials exist. */ }
|
|
43
|
+
const shared = Object.fromEntries(['provider', 'user', 'password', 'senderName', 'authUser', 'authPassword', 'clientId', 'authKind', 'imap', 'smtp', 'inboxFolder']
|
|
44
|
+
.filter(key => config[key] !== undefined).map(key => [key, config[key]]));
|
|
45
|
+
const cards = config.accounts && Object.keys(config.accounts).length > 0
|
|
46
|
+
? Object.fromEntries(Object.entries(config.accounts).map(([key, account]) => [key, { ...shared, ...account }]))
|
|
47
|
+
: typeof config.user === 'string' && config.user !== '' ? { default: shared } : {};
|
|
48
|
+
return EmailSettingsSchema({ ...toSettingsBase(config), ...endpointDefaults,
|
|
49
|
+
accountsYaml: config.accountsYaml ?? serializeAccountsYaml(cards, config.defaultAccount),
|
|
50
|
+
serverPresets: config.serverPresets ?? '',
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
};
|
|
28
54
|
const getSettingsValue = () => settingsScope.get();
|
|
29
55
|
const getEffectiveSettings = () => {
|
|
30
56
|
// Form defaults must not overwrite row settings or provider presets.
|
|
31
|
-
|
|
57
|
+
if (!legacy)
|
|
58
|
+
return resolveEmailSettings({ ...config });
|
|
59
|
+
const descriptor = (ctx.settings.describe?.() ?? []).find(row => row.ns === namespace);
|
|
32
60
|
const value = getSettingsValue();
|
|
33
61
|
// serverPresets is a *lookup source* for provider ids, not part of the
|
|
34
62
|
// resolved config: it is handed to resolution here and never stored on the
|
package/lib/settings.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import z from 'schemastery';
|
|
2
1
|
import { type EmailConfig } from './config.js';
|
|
3
2
|
/** Settings-document namespace this plugin owns (editable from the Web settings page). */
|
|
4
3
|
export declare const SETTINGS_NAMESPACE = "dsh-email";
|
|
@@ -7,63 +6,63 @@ export declare const SETTINGS_NAMESPACE = "dsh-email";
|
|
|
7
6
|
* The form edits the default/shorthand account; its advanced YAML area
|
|
8
7
|
* edits named accounts through accountsYaml.
|
|
9
8
|
*/
|
|
10
|
-
export declare const EmailSettingsSchema:
|
|
11
|
-
provider:
|
|
12
|
-
user:
|
|
13
|
-
password:
|
|
14
|
-
inboxFolder:
|
|
15
|
-
sendApproval:
|
|
16
|
-
maxBodyChars:
|
|
17
|
-
downloadDir:
|
|
18
|
-
accountsYaml:
|
|
19
|
-
serverPresets:
|
|
20
|
-
imap:
|
|
21
|
-
host:
|
|
22
|
-
port:
|
|
23
|
-
secure:
|
|
24
|
-
}
|
|
25
|
-
host:
|
|
26
|
-
port:
|
|
27
|
-
secure:
|
|
28
|
-
}
|
|
29
|
-
smtp:
|
|
30
|
-
host:
|
|
31
|
-
port:
|
|
32
|
-
secure:
|
|
33
|
-
}
|
|
34
|
-
host:
|
|
35
|
-
port:
|
|
36
|
-
secure:
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
provider:
|
|
40
|
-
user:
|
|
41
|
-
password:
|
|
42
|
-
inboxFolder:
|
|
43
|
-
sendApproval:
|
|
44
|
-
maxBodyChars:
|
|
45
|
-
downloadDir:
|
|
46
|
-
accountsYaml:
|
|
47
|
-
serverPresets:
|
|
48
|
-
imap:
|
|
49
|
-
host:
|
|
50
|
-
port:
|
|
51
|
-
secure:
|
|
52
|
-
}
|
|
53
|
-
host:
|
|
54
|
-
port:
|
|
55
|
-
secure:
|
|
56
|
-
}
|
|
57
|
-
smtp:
|
|
58
|
-
host:
|
|
59
|
-
port:
|
|
60
|
-
secure:
|
|
61
|
-
}
|
|
62
|
-
host:
|
|
63
|
-
port:
|
|
64
|
-
secure:
|
|
65
|
-
}
|
|
66
|
-
}
|
|
9
|
+
export declare const EmailSettingsSchema: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
|
|
10
|
+
provider: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
11
|
+
user: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
12
|
+
password: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
13
|
+
inboxFolder: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
14
|
+
sendApproval: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
15
|
+
maxBodyChars: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
16
|
+
downloadDir: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
17
|
+
accountsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
18
|
+
serverPresets: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
19
|
+
imap: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
|
|
20
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
21
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
22
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
23
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
24
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
25
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
26
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
27
|
+
}>>, "plain">;
|
|
28
|
+
smtp: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
|
|
29
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
30
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
31
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
32
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
33
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
34
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
35
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
36
|
+
}>>, "plain">;
|
|
37
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
38
|
+
provider: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
39
|
+
user: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
40
|
+
password: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
41
|
+
inboxFolder: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
42
|
+
sendApproval: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
43
|
+
maxBodyChars: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
44
|
+
downloadDir: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
45
|
+
accountsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
46
|
+
serverPresets: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
47
|
+
imap: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
|
|
48
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
49
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
50
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
51
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
52
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
53
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
54
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
55
|
+
}>>, "plain">;
|
|
56
|
+
smtp: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
|
|
57
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
58
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
59
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
60
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
61
|
+
host: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
62
|
+
port: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
63
|
+
secure: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
|
|
64
|
+
}>>, "plain">;
|
|
65
|
+
}>>, "plain">;
|
|
67
66
|
export interface EmailSettingsValue {
|
|
68
67
|
provider: string;
|
|
69
68
|
user: string;
|
package/lib/web.d.ts
CHANGED
|
@@ -178,6 +178,8 @@ export declare class EmailSettingsBackend {
|
|
|
178
178
|
constructor(ctx: any, scope: any, rowConfig: EmailConfig);
|
|
179
179
|
/** Wired by apply(): the email_watch core; 'web' keeps its own cursor scope. */
|
|
180
180
|
watchImpl?: (account: string, folder: string, limit: number, scope: string) => Promise<EmailWatchResult>;
|
|
181
|
+
private get namespace();
|
|
182
|
+
private get currentConfig();
|
|
181
183
|
private userSection;
|
|
182
184
|
/** Effective config for the stored value (row + user-set fields only). */
|
|
183
185
|
private effectiveStored;
|
package/lib/web.js
CHANGED
|
@@ -757,12 +757,16 @@ export class EmailSettingsBackend {
|
|
|
757
757
|
}
|
|
758
758
|
/** Wired by apply(): the email_watch core; 'web' keeps its own cursor scope. */
|
|
759
759
|
watchImpl;
|
|
760
|
+
get namespace() { return this.scope.namespace ?? SETTINGS_NAMESPACE; }
|
|
761
|
+
get currentConfig() { return this.scope.config ?? this.rowConfig; }
|
|
760
762
|
userSection() {
|
|
761
|
-
const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns ===
|
|
763
|
+
const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === this.namespace);
|
|
762
764
|
return descriptor?.user;
|
|
763
765
|
}
|
|
764
766
|
/** Effective config for the stored value (row + user-set fields only). */
|
|
765
767
|
effectiveStored() {
|
|
768
|
+
if (this.scope.config)
|
|
769
|
+
return { ...this.currentConfig };
|
|
766
770
|
const stored = this.scope.get();
|
|
767
771
|
const merged = { ...this.rowConfig, ...toEmailConfig(stored, this.userSection()) };
|
|
768
772
|
// toEmailConfig drops serverPresets — it must never enter the fingerprint —
|
|
@@ -771,14 +775,14 @@ export class EmailSettingsBackend {
|
|
|
771
775
|
return { ...merged, ...(typeof stored?.serverPresets === 'string' ? { serverPresets: stored.serverPresets } : {}) };
|
|
772
776
|
}
|
|
773
777
|
async snapshot() {
|
|
774
|
-
const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns ===
|
|
778
|
+
const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === this.namespace);
|
|
775
779
|
const value = this.scope.get();
|
|
776
780
|
const whale = findWhaleAsset();
|
|
777
781
|
const presets = presetsSnapshot(value.serverPresets);
|
|
778
782
|
// The cards describe the *effective* accountsYaml — the same text the
|
|
779
783
|
// advanced editor shows, and the same source the accounts field reads.
|
|
780
784
|
const effective = this.effectiveStored();
|
|
781
|
-
const draft = readAccountsDraft(effective.accountsYaml ?? '', presets.custom, effective.defaultAccount, tokenLookup());
|
|
785
|
+
const draft = readAccountsDraft(this.scope.config ? value.accountsYaml : effective.accountsYaml ?? '', presets.custom, effective.defaultAccount, tokenLookup());
|
|
782
786
|
return {
|
|
783
787
|
settings: {
|
|
784
788
|
value,
|
|
@@ -813,7 +817,17 @@ export class EmailSettingsBackend {
|
|
|
813
817
|
// one of them is a legal choice rather than an unknown provider.
|
|
814
818
|
validateSettingsValue(value, presetNamesIn(value?.serverPresets ?? this.scope.get()?.serverPresets));
|
|
815
819
|
const before = accountNamesOf(this.scope.get());
|
|
816
|
-
|
|
820
|
+
if (this.scope.config) {
|
|
821
|
+
// The card editor takes ownership of legacy shorthand/maps. Clear only
|
|
822
|
+
// the superseded account storage, so removing the last card cannot
|
|
823
|
+
// resurrect a hidden old account on the next read.
|
|
824
|
+
const section = Object.hasOwn(value, 'accountsYaml')
|
|
825
|
+
? { ...value, accounts: {}, user: '', password: '', authUser: '', authPassword: '' }
|
|
826
|
+
: value;
|
|
827
|
+
await this.ctx.settings.update(this.namespace, section, expectedRevision);
|
|
828
|
+
}
|
|
829
|
+
else
|
|
830
|
+
await this.ctx.settings.replace(this.namespace, value, expectedRevision);
|
|
817
831
|
// A deleted account must not leave its refresh token behind: the store is
|
|
818
832
|
// keyed by account name, so the credential of a mailbox that is no longer
|
|
819
833
|
// configured would sit on disk, and a later account reusing that name would
|
|
@@ -842,7 +856,7 @@ export class EmailSettingsBackend {
|
|
|
842
856
|
const draft = toEmailConfig(value, null);
|
|
843
857
|
const presets = value?.serverPresets ?? this.scope.get()?.serverPresets;
|
|
844
858
|
const settings = resolveEmailSettings({
|
|
845
|
-
...this.
|
|
859
|
+
...this.currentConfig,
|
|
846
860
|
...draft,
|
|
847
861
|
...(typeof presets === 'string' ? { serverPresets: presets } : {}),
|
|
848
862
|
});
|
|
@@ -1070,7 +1084,7 @@ export class EmailSettingsBackend {
|
|
|
1070
1084
|
const presets = typeof body.serverPresets === 'string'
|
|
1071
1085
|
? customPresetsOf(body.serverPresets).custom
|
|
1072
1086
|
: customPresetsOf(this.scope.get().serverPresets).custom;
|
|
1073
|
-
const draft = readAccountsDraft(text, presets, this.
|
|
1087
|
+
const draft = readAccountsDraft(text, presets, this.currentConfig.defaultAccount, tokenLookup());
|
|
1074
1088
|
this.responseJson(res, 200, {
|
|
1075
1089
|
ok: true,
|
|
1076
1090
|
value: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH 邮件插件:IMAP/SMTP
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "DSH 邮件插件:IMAP/SMTP 收发搜索、回复转发、附件与整理,多账号卡片设置页、Outlook OAuth2 登录与发信审批。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
"mailparser": "^3.9.22",
|
|
66
66
|
"nodemailer": "^9.0.5",
|
|
67
67
|
"schemastery": "^3.18.0",
|
|
68
|
-
"yaml": "^2.9.0"
|
|
68
|
+
"yaml": "^2.9.0",
|
|
69
|
+
"@deepseek-ai/schemastery": "^3.18.3"
|
|
69
70
|
},
|
|
70
71
|
"devDependencies": {
|
|
71
72
|
"@types/node": "^26.2.0",
|