dsh-email 0.13.2 → 0.14.1

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
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1(2026-09-27)
4
+
5
+ - 未补全的账号和服务器预设会显示未保存提示;避免草稿丢失和错误的“已保存”状态。测试连接只校验所选账号,错误直接指向设置页中的对应字段。
6
+
3
7
  > 完整历史(含详细改动说明)。README 只保留最近几个版本的一句话摘要。
4
8
 
5
9
  ## 中文版
package/README.en.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # dsh-email
2
2
 
3
+ ## 0.14.1 update (2026-09-27)
4
+
5
+ Incomplete account and server-preset drafts display an unsaved status and are protected against accidental navigation. Connection tests validate the selected account independently and identify the relevant settings field.
6
+
7
+ Validation host: Harness 0.1.7-rc.2 built from official sources, retaining the local tool-scheduler fix. Build and automated checks pass; interactive coverage and external-service limits are recorded in this release round.
8
+
3
9
  ![npm](https://img.shields.io/npm/v/dsh-email) ![downloads](https://img.shields.io/npm/dm/dsh-email) ![license](https://img.shields.io/github/license/STARDUSTLC666/dsh-email) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-email?style=social)
4
10
 
5
11
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
@@ -37,6 +43,8 @@ Example:
37
43
 
38
44
  ### Changelog
39
45
 
46
+ - **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.
47
+
40
48
  - **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.
41
49
  - **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.
42
50
  - **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.
@@ -45,6 +53,8 @@ Example:
45
53
  - **0.10.8 and earlier**: see [CHANGELOG.md](CHANGELOG.md).
46
54
  ## Compatibility
47
55
 
56
+ 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.
57
+
48
58
  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.
49
59
 
50
60
  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.
@@ -53,7 +63,7 @@ Co-load verification was performed on 2026-09-16 with official source builds of
53
63
 
54
64
  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.
55
65
 
56
- 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, with no runtime imports of `@deepseek-ai/*` internals. 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.
66
+ 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.
57
67
 
58
68
  ## Installation
59
69
 
@@ -74,7 +84,7 @@ The whole settings page follows DSH's light and dark themes: every panel style r
74
84
 
75
85
  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.
76
86
 
77
- Values saved in the settings page live in the `dsh-email` namespace of `settings.yaml` and override the YAML default-account config. 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.
87
+ 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.
78
88
 
79
89
  ## Uninstall
80
90
 
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  # dsh-email
4
4
 
5
+ ## 0.14.1 更新(2026-09-27)
6
+
7
+ 未补全的账号和服务器预设会显示未保存提示;避免草稿丢失和错误的“已保存”状态。测试连接只校验所选账号,错误直接指向设置页中的对应字段。
8
+
9
+ 验证宿主:官方源码构建的 Harness 0.1.7-rc.2(保留本地工具调度器修复)。构建与自动测试通过;实际操作和外部服务限制见本轮验收记录。
10
+
5
11
  > **让 agent 协助处理邮件**:收发、搜索、回复转发、附件、邮件整理与新邮件提醒,支持八种常见邮箱服务预设。
6
12
 
7
13
  ![npm version](https://img.shields.io/npm/v/dsh-email?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-email) ![license](https://img.shields.io/npm/l/dsh-email) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-email?style=social)
@@ -45,6 +51,8 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
45
51
 
46
52
  ### 版本记录
47
53
 
54
+ - **0.14.0(2026-09-23)**:适配 Harness 0.1.7 设置接口;自动导入旧版邮箱设置,旧账号直接显示为可编辑卡片。编辑即时保存,刷新后保留;保留高级参数与已有密码,清空账号后不再重新出现。
55
+
48
56
  - **0.13.2(2026-09-21)**:同名附件优先按真实 MIME 分段编号下载;旧解析元数据按一对一匹配,避免多个序号都取到第一个同名文件。保留正文分段下载和附件索引缓存,新增文件字节级回归,268 项测试通过。
49
57
  - **0.13.1(2026-09-19)**:内置一份社区公共客户端注册(感谢 [gurio-wine](https://github.com/gurio-wine)),Outlook / Exchange Online 的 OAuth2 登录开箱即用;想用自己的应用仍可填 `clientId` 覆盖,设置页会显示当前生效的是哪个应用。测试 264 项。
50
58
  - **0.13.0(2026-09-18)**:修复长正文截断成空、`email_watch` 永久漏报新邮件、附件缓存跨 UIDVALIDITY 失效;10 个工具声明超时;读信/搜索只下正文分段;搜索回退标明扫描口径。测试 262 项。
@@ -53,6 +61,8 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
53
61
  - **0.10.8 及更早**:见 [CHANGELOG.md](CHANGELOG.md)。
54
62
  ## 兼容性
55
63
 
64
+ 当前验证基线为官方源码构建的 Harness **0.1.7-alpha.2**(2026-09-23,含本地工具调度器 `Symbol.for` 修复)。18 个插件共同加载;邮件设置已通过旧配置迁移、真实网页编辑与自动保存、刷新恢复、修订冲突和重启持久化检查。本轮使用隔离测试账号,未连接真实邮箱或发送邮件。
65
+
56
66
  2026-09-21:当前发布包经官方 CLI 安装到隔离 profile,在源码构建的 Harness `0.1.6-alpha.2` 上与另外两个下载量前三插件共同加载,18 个插件工具注册正常;日历/邮件配置自检、PPT 主题查询和 17 行表格生成通过。测试本体基于官方 alpha.2 发布提交,另含工具调度器 `Symbol.for` 修复(`93badd88`)。本轮未连接真实邮箱或日历服务。
57
67
 
58
68
  2026-09-16 曾在官方源码构建的 Harness `0.1.5-rc.2` 和 `0.1.6-alpha.1` 上完成同载验证:18 个组件与 ModLens 同载,工具 schema、技能注册及离线只读调用检查通过。
@@ -61,7 +71,7 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
61
71
 
62
72
  2026-09-10,npm `dsh-email@0.10.6` 曾通过真实 QQ 邮箱目录、列表、读取和搜索,以及设置页“测试连接”“保存并应用”检查;授权码留空时能继续使用 `DSH_EMAIL_PASSWORD`。独立 SMTP 登录认证也已通过。此次复验未连接真实邮箱,未发送、修改或删除邮件。
63
73
 
64
- 遵循官方[插件打包与安装要求](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 参数、规范化输出和渲染函数,运行时不 import `@deepseek-ai/*` 内部模块。使用 Node 22.19 及以上的 22.x 或 Node 24 及以上版本;Harness 仍在快速迭代,上述版本是实测基线。
74
+ 遵循官方[插件打包与安装要求](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 仍在快速迭代,上述版本是实测基线。
65
75
 
66
76
  ## 安装
67
77
 
@@ -82,7 +92,7 @@ dsh plugin --profile web add dsh-email
82
92
 
83
93
  多账号可以在设置页可视化编辑:账号卡片支持增删改账号、改名、设默认、按账号名单独「测试连接」;没填完的账号不阻断保存,只标一个「未完成」。卡片改动即时防抖落盘,不再有"先写 YAML 文本、再点一次保存"这一步;版本冲突(别处也改了设置)会自动重基后重存一次,而不是拿旧版本号反复失败。保存卡片时,已存的授权码默认保持(密码栏留空 = 不变,填内容 = 覆盖);YAML 里的注释尽量原地保留,实在保不住时会明确提示。改名走的是原地改键,授权码与高级键一并保留,且不允许改成已有账号名(那会顶掉另一个账号)。账号自己手写的 imap/smtp 端点只在**服务商真的换了**时才清洗——运行时以账号自己的 host 优先,普通保存不会悄悄改动连接目标。
84
94
 
85
- 设置页保存的值存在 `settings.yaml` 的 `dsh-email` 命名空间里,覆盖 YAML 的默认账号配置。授权码字段标记为 secret,但填写后保存仍会写入本机配置文件。单账号如需避免保存授权码,可设置 `DSH_EMAIL_PASSWORD` 并将授权码栏留空;环境变量不会被复制进设置文件。
95
+ Harness 0.1.7 中,设置页直接保存到当前 profile 的 `tool-email` 配置行,修改立即生效。使用默认插件行的旧安装会自动导入 `settings.yaml` 或 `settings.yaml.imported` 中的 `dsh-email` 设置;已有 profile 配置优先,原文件保留且只迁移一次,无需重新输入账号。旧版宿主仍使用原 settings 存储。授权码字段标记为 secret,但填写后保存仍会写入本机配置文件。单账号如需避免保存授权码,可设置 `DSH_EMAIL_PASSWORD` 并将授权码栏留空;环境变量不会被复制进设置文件。
86
96
 
87
97
  ## 卸载
88
98
 
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
- const ROUTE = "/_dsh/dsh-email/settings";
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
 
@@ -207,6 +208,7 @@ const CSS = [
207
208
  ".dshe-auto-status{font-size:12px;font-weight:600;align-self:center;color:var(--dsw-alias-label-tertiary,#77736d)}",
208
209
  ".dshe-auto-status.is-saved{color:var(--dsw-alias-state-success-primary,#267d52)}",
209
210
  ".dshe-auto-status.is-error{color:var(--dsw-alias-state-error-primary,#aa3939)}",
211
+ ".dshe-auto-status.is-draft{color:var(--dsw-alias-state-warning-primary,#986000)}",
210
212
  ".dshe-acc-badge.is-oauth{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 12%,transparent);color:var(--dsw-alias-state-success-primary,#267d52)}",
211
213
  ".dshe-oauth{display:grid;gap:8px;padding:10px 11px;border:1px dashed var(--dsw-alias-border-l2,#dedbd5);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff)}",
212
214
  ".dshe-oauth.is-logged-in{border-style:solid;border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 34%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 8%,transparent)}",
@@ -245,8 +247,8 @@ var UI = {
245
247
  // 导航 / 段标题
246
248
  "nav.title": "邮件 (dsh-email)",
247
249
  "section.title": "邮件",
248
- "section.intro": "在这里配置邮箱账号,即可使用 10 个 email_* 工具。每个账号只记服务商与凭证,端点在连接时从服务器预设展开;改动自动保存、立即生效。",
249
- "section.readonly": "当前 settings 存储是只读的,只能查看不能保存。",
250
+ "section.intro": "添加邮箱后,就可以让助手帮你查收、搜索和回复邮件。选择服务商并填写邮箱地址与授权码,修改会自动保存。",
251
+ "section.readonly": "当前配置为只读,可以查看,但无法保存修改。",
250
252
  "section.activeAccounts": "当前生效账号:{accounts}",
251
253
 
252
254
  // 内置服务商名(PROVIDERS 里存的就是这些 key)
@@ -259,18 +261,19 @@ var UI = {
259
261
  "provider.outlook": "Outlook",
260
262
  "provider.icloud": "iCloud",
261
263
  "provider.custom": "自定义",
262
- "provider.noneInYaml": "(YAML 未指定服务商)",
264
+ "provider.noneInYaml": "请选择邮箱服务商",
263
265
  "provider.undefinedPresetSuffix": "(未定义预设)",
264
266
 
265
267
  // 全局设置
266
268
  "global.sendApproval": "发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
267
- "global.downloadDirLabel": "附件下载目录(默认 $DSH_HOME/email-downloads)",
269
+ "global.downloadDirLabel": "附件下载目录(默认保存在当前工作区)",
268
270
  "global.downloadDirPlaceholder": "留空使用默认",
269
271
  "global.hint": "以上两项对所有账号统一生效。",
270
272
 
271
273
  // 自动保存状态条
272
274
  "status.saved": "已保存",
273
275
  "status.saving": "保存中…",
276
+ "status.draft": "还有未保存的内容,请补全下方标记的字段。",
274
277
  "status.saveFailed": "保存失败:{message}(点击重试)",
275
278
 
276
279
  // 账号卡片
@@ -299,9 +302,9 @@ var UI = {
299
302
  "card.errReservedName": "账号名不能是 defaultAccount(该键保留给默认账号),这份改动先不保存。",
300
303
  "card.errNoDefault": "有多个账号还没指定默认账号,这份改动先不保存。",
301
304
  "card.yamlParseFailed": "accountsYaml 解析失败:{error}",
302
- "card.confirmDelete": "删除账号「{name}」会在保存后从 YAML 里移除,已存密码一并丢失。",
303
- "card.nameLabel": "账号名(工具调用的 account 参数)",
304
- "card.nameHint": "不能叫 defaultAccount(该键保留给默认账号)。改名会删旧键、建新键。",
305
+ "card.confirmDelete": "删除账号「{name}」会同时移除已保存的登录信息,不会删除邮箱里的邮件。",
306
+ "card.nameLabel": "账号名称",
307
+ "card.nameHint": "用来区分多个邮箱,例如「工作邮箱」。修改名称会保留原有设置;名称不能为 defaultAccount。",
305
308
  "card.reservedNameWarn": "defaultAccount 是保留键(表示默认账号),请换一个名字。",
306
309
  "card.providerLabel": "服务商",
307
310
  "card.addressLabel": "邮箱地址",
@@ -313,7 +316,7 @@ var UI = {
313
316
  "card.passwordLabel": "授权码 / 应用专用密码",
314
317
  "card.passwordPlaceholder": "留空保持不变",
315
318
  "card.passwordSaved": "已存有授权码:留空保持不变,清空后填内容即覆盖。",
316
- "card.passwordHint": "留空即不写入 password 键。",
319
+ "card.passwordHint": "请填写邮箱服务商提供的授权码或应用专用密码,然后点击「测试连接」。",
317
320
  "card.senderNameLabel": "发件显示名(可选)",
318
321
  "card.senderNamePlaceholder": "只影响收件人看到的名称",
319
322
  "card.senderNameHint": "只改收件人看到的显示名,发件地址仍是上面的邮箱地址。",
@@ -424,7 +427,7 @@ var UI = {
424
427
  // 导航 / 段标题
425
428
  "nav.title": "Email (dsh-email)",
426
429
  "section.title": "Email",
427
- "section.intro": "Configure mailbox accounts here to use the ten email_* tools. An account stores only its provider and credentials; endpoints are expanded from the server preset at connect time. Changes save automatically and take effect at once.",
430
+ "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
431
  "section.readonly": "The settings store is read-only right now: you can view the settings but not save them.",
429
432
  "section.activeAccounts": "Active accounts: {accounts}",
430
433
 
@@ -438,18 +441,19 @@ var UI = {
438
441
  "provider.outlook": "Outlook",
439
442
  "provider.icloud": "iCloud",
440
443
  "provider.custom": "Custom",
441
- "provider.noneInYaml": "(no provider in YAML)",
444
+ "provider.noneInYaml": "Choose your email provider",
442
445
  "provider.undefinedPresetSuffix": "(undefined preset)",
443
446
 
444
447
  // 全局设置
445
448
  "global.sendApproval": "Ask for confirmation before sending (strongly recommended; auto-denied under Full Access)",
446
- "global.downloadDirLabel": "Attachment download directory (defaults to $DSH_HOME/email-downloads)",
449
+ "global.downloadDirLabel": "Attachment download directory (defaults to the current workspace)",
447
450
  "global.downloadDirPlaceholder": "Leave empty for the default",
448
451
  "global.hint": "These two settings apply to every account.",
449
452
 
450
453
  // 自动保存状态条
451
454
  "status.saved": "Saved",
452
455
  "status.saving": "Saving…",
456
+ "status.draft": "Some edits are not saved yet. Complete the highlighted fields below.",
453
457
  "status.saveFailed": "Save failed: {message} (click to retry)",
454
458
 
455
459
  // 账号卡片
@@ -478,9 +482,9 @@ var UI = {
478
482
  "card.errReservedName": "An account cannot be named defaultAccount (that key is reserved for the default account); this change will not be saved.",
479
483
  "card.errNoDefault": "Several accounts exist but no default account was chosen; this change will not be saved.",
480
484
  "card.yamlParseFailed": "accountsYaml failed to parse: {error}",
481
- "card.confirmDelete": "Deleting account “{name}” removes it from the YAML once saved, and its stored password is lost with it.",
482
- "card.nameLabel": "Account name (the account argument of tool calls)",
483
- "card.nameHint": "Cannot be defaultAccount (that key is reserved for the default account). Renaming deletes the old key and creates a new one.",
485
+ "card.confirmDelete": "Deleting account “{name}” also removes its saved login details. Messages in the mailbox are not deleted.",
486
+ "card.nameLabel": "Account name",
487
+ "card.nameHint": "Use a name such as “Work” to distinguish your mailboxes. Renaming keeps the existing settings; defaultAccount is reserved.",
484
488
  "card.reservedNameWarn": "defaultAccount is a reserved key (it means the default account); pick another name.",
485
489
  "card.providerLabel": "Provider",
486
490
  "card.addressLabel": "Email address",
@@ -492,7 +496,7 @@ var UI = {
492
496
  "card.passwordLabel": "Authorization code / app password",
493
497
  "card.passwordPlaceholder": "Leave empty to keep it",
494
498
  "card.passwordSaved": "A password is already stored: leave empty to keep it, or clear the field and type to overwrite.",
495
- "card.passwordHint": "Leave it empty and the password key is not written.",
499
+ "card.passwordHint": "Enter the authorization code or app password from your email provider, then click “Test connection”.",
496
500
  "card.senderNameLabel": "Sender display name (optional)",
497
501
  "card.senderNamePlaceholder": "Only the name recipients see",
498
502
  "card.senderNameHint": "Changes only the name recipients see; the address stays the one above.",
@@ -1349,33 +1353,38 @@ function AccountCardsEditor(props) {
1349
1353
  */
1350
1354
  const autoSave = (draftsNow, orderNow, defaultNow, immediate) => {
1351
1355
  if (typeof props.onAutoSave !== "function") return null;
1356
+ const incomplete = (text) => {
1357
+ setNotice({ kind: "error", text });
1358
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(true);
1359
+ };
1352
1360
  const names = orderNow.map((row) => row.key).filter((name) => Object.prototype.hasOwnProperty.call(draftsNow, name));
1353
1361
  let built;
1354
1362
  try {
1355
1363
  built = names.map((name) => draftToInput(draftsNow[name]));
1356
1364
  } catch (e) {
1357
- console.error("[dsh-email] 卡片快照组装失败", e, names, draftsNow);
1365
+ incomplete(e && e.message ? e.message : String(e));
1358
1366
  return null;
1359
1367
  }
1360
1368
  if (built.some((card) => String(card.name || "").trim() === "")) {
1361
- setNotice({ kind: "error", text: t("card.errNoName") });
1369
+ incomplete(t("card.errNoName"));
1362
1370
  return null;
1363
1371
  }
1364
1372
  if (names.some((name) => !draftsNow[name].provider && !(draftsNow[name].imap?.host && draftsNow[name].smtp?.host))) {
1365
1373
  // 服务商就是账号的连接身份:没有它,后端写回时会把 provider 键删掉,这个账号
1366
1374
  // 永远连不上。名字和它都属于「这还是一张半填的卡」,一起拦在这里。
1367
- setNotice({ kind: "error", text: t("card.errNoProvider") });
1375
+ incomplete(t("card.errNoProvider"));
1368
1376
  return null;
1369
1377
  }
1370
1378
  if (built.some((card) => card.name === "defaultAccount")) {
1371
- setNotice({ kind: "error", text: t("card.errReservedName") });
1379
+ incomplete(t("card.errReservedName"));
1372
1380
  return null;
1373
1381
  }
1374
1382
  if (built.length > 1 && String(defaultNow || "") === "") {
1375
- setNotice({ kind: "error", text: t("card.errNoDefault") });
1383
+ incomplete(t("card.errNoDefault"));
1376
1384
  return null;
1377
1385
  }
1378
1386
  setNotice(null);
1387
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(false);
1379
1388
  // 返回值只给 OAuth 登录用:它必须等这份配置真的落盘(后端按账号名去读 provider
1380
1389
  // 和端点)。其余调用方照旧忽略返回值。
1381
1390
  return props.onAutoSave(built, String(defaultNow || ""), immediate === true);
@@ -1888,9 +1897,11 @@ function ServerPresetsEditor(props) {
1888
1897
  const result = serializePresetDrafts(nextOrder.map((row) => row.key), nextDrafts);
1889
1898
  if (result.error !== undefined) {
1890
1899
  setNotice({ kind: "error", text: result.error });
1900
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(true);
1891
1901
  return;
1892
1902
  }
1893
1903
  setNotice(null);
1904
+ if (typeof props.onDraftStatus === "function") props.onDraftStatus(false);
1894
1905
  props.onAutoSave(result.text);
1895
1906
  };
1896
1907
 
@@ -2108,6 +2119,8 @@ function EmailSettingsSection() {
2108
2119
  const [error, setError] = useState("");
2109
2120
  /** 自动保存的可见状态:null 视同「已保存」(刚加载完、什么都没改)。 */
2110
2121
  const [saveState, setSaveState] = useState(null);
2122
+ const [incompleteDrafts, setIncompleteDrafts] = useState({ accounts: false, presets: false });
2123
+ const hasIncompleteDraft = incompleteDrafts.accounts || incompleteDrafts.presets;
2111
2124
  /** 卡片级提示(注释丢失、序列化失败…):走卡片自己的 notice 位,不打断顶部状态。 */
2112
2125
  const [cardsNotice, setCardsNotice] = useState(null);
2113
2126
  /**
@@ -2162,6 +2175,7 @@ function EmailSettingsSection() {
2162
2175
  accountsYamlRef.current = next.accountsYaml || "";
2163
2176
  savedSignatureRef.current = signatureOf(next, next.accountsYaml || "");
2164
2177
  setSaveState(null);
2178
+ setIncompleteDrafts({ accounts: false, presets: false });
2165
2179
  setCardsNotice(null);
2166
2180
  setCustomNames(presetNamesOf(next.serverPresets));
2167
2181
  setCardsDetail(snap.accountsDetail);
@@ -2183,6 +2197,7 @@ function EmailSettingsSection() {
2183
2197
  * 防抖那条路交不出 promise(定时器还没到点),返回 null。
2184
2198
  */
2185
2199
  const scheduleSave = (immediate) => {
2200
+ setSaveState({ kind: "busy" });
2186
2201
  // 用户又改了:还在飞的那次保存结果就作废,别拿旧结果盖住新状态。
2187
2202
  seqRef.current += 1;
2188
2203
  if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
@@ -2269,14 +2284,14 @@ function EmailSettingsSection() {
2269
2284
  // 有没落盘的内容(保存中或保存失败)才拦离开:这正是「编辑即保存」剩下的窗口。
2270
2285
  useEffect(() => {
2271
2286
  const guard = (e) => {
2272
- if (saveState === null || saveState.kind === "saved") return undefined;
2287
+ if (!hasIncompleteDraft && (saveState === null || saveState.kind === "saved")) return undefined;
2273
2288
  e.preventDefault();
2274
2289
  e.returnValue = "";
2275
2290
  return "";
2276
2291
  };
2277
2292
  window.addEventListener("beforeunload", guard);
2278
2293
  return () => window.removeEventListener("beforeunload", guard);
2279
- }, [saveState]);
2294
+ }, [saveState, hasIncompleteDraft]);
2280
2295
 
2281
2296
  /** 卡片序列化的往返序号:打字快时多个 serializeAccounts 会并发,只认最后一个。 */
2282
2297
  const cardsSeqRef = useRef(0);
@@ -2311,6 +2326,7 @@ function EmailSettingsSection() {
2311
2326
  */
2312
2327
  const onCardsAutoSave = async (cards, defaultAccount, immediate) => {
2313
2328
  const seq = ++cardsSeqRef.current;
2329
+ setSaveState({ kind: "busy" });
2314
2330
  try {
2315
2331
  const result = await serializeCards(cards, defaultAccount);
2316
2332
  if (seq !== cardsSeqRef.current) return { ok: false, reason: "superseded" }; // 更晚的一次已经在路上,别用旧结果盖新状态
@@ -2332,8 +2348,10 @@ function EmailSettingsSection() {
2332
2348
  if (flushed && typeof flushed.then === "function") await flushed;
2333
2349
  return { ok: true };
2334
2350
  } catch (e) {
2351
+ if (seq !== cardsSeqRef.current) return { ok: false, reason: "superseded" };
2335
2352
  const message = e && e.message ? e.message : String(e);
2336
2353
  setCardsNotice({ kind: "error", text: t("notice.cardsNotWritten", { message }) });
2354
+ setSaveState({ kind: "error", text: message });
2337
2355
  return { ok: false, reason: message };
2338
2356
  }
2339
2357
  };
@@ -2359,7 +2377,7 @@ function EmailSettingsSection() {
2359
2377
 
2360
2378
  const accounts = snapshot && snapshot.accounts ? snapshot.accounts : [];
2361
2379
  const presetsMerged = presetsWithNames((snapshot && snapshot.presets) || undefined, customNames);
2362
- const statusText = saveState === null
2380
+ const statusText = hasIncompleteDraft ? t("status.draft") : saveState === null
2363
2381
  ? t("status.saved")
2364
2382
  : saveState.kind === "busy"
2365
2383
  ? t("status.saving")
@@ -2367,9 +2385,11 @@ function EmailSettingsSection() {
2367
2385
  ? t("status.saved")
2368
2386
  : t("status.saveFailed", { message: saveState.text });
2369
2387
  const status = h("span", {
2370
- className: "dshe-auto-status" + (saveState === null ? "" : " is-" + saveState.kind),
2371
- onClick: saveState !== null && saveState.kind === "error" ? () => flushSave() : undefined,
2372
- style: saveState !== null && saveState.kind === "error" ? { cursor: "pointer" } : undefined,
2388
+ role: "status",
2389
+ "aria-live": "polite",
2390
+ className: "dshe-auto-status" + (hasIncompleteDraft ? " is-draft" : saveState === null ? "" : " is-" + saveState.kind),
2391
+ onClick: !hasIncompleteDraft && saveState !== null && saveState.kind === "error" ? () => flushSave() : undefined,
2392
+ style: !hasIncompleteDraft && saveState !== null && saveState.kind === "error" ? { cursor: "pointer" } : undefined,
2373
2393
  }, statusText);
2374
2394
 
2375
2395
  return h("div", { className: "dshe-settings" }, [
@@ -2402,6 +2422,7 @@ function EmailSettingsSection() {
2402
2422
  presets: presetsMerged,
2403
2423
  extraNotice: cardsNotice,
2404
2424
  onAutoSave: onCardsAutoSave,
2425
+ onDraftStatus: (blocked) => setIncompleteDrafts((current) => ({ ...current, accounts: blocked })),
2405
2426
  onTest: onCardTest,
2406
2427
  // OAuth2 登录成功后重拉整份快照:卡片的 authKind/oauthState/oauthUser 是
2407
2428
  // 服务端算出来的,只有新快照才拿得到(和加载走同一条路径)。
@@ -2412,6 +2433,7 @@ function EmailSettingsSection() {
2412
2433
  h(ServerPresetsEditor, {
2413
2434
  presets: presetsAtLoad,
2414
2435
  onAutoSave: (text) => update({ serverPresets: typeof text === "string" ? text : "" }),
2436
+ onDraftStatus: (blocked) => setIncompleteDrafts((current) => ({ ...current, presets: blocked })),
2415
2437
  }),
2416
2438
  ]),
2417
2439
  snapshot && snapshot.writable === false
@@ -2513,7 +2535,7 @@ function startWhaleWidget() {
2513
2535
  const snap = await api();
2514
2536
  lastSnapshot = Date.now();
2515
2537
  if (snap && snap.whale) {
2516
- whaleUrl = snap.whale.url || "";
2538
+ whaleUrl = (snap.whale.url || "").replace(/^\/_dsh\//, "_dsh/");
2517
2539
  whaleCredit = snap.whale.credit || "";
2518
2540
  }
2519
2541
  hasAccounts = !!(snap && snap.accounts && snap.accounts.length > 0);
package/lib/config.d.ts CHANGED
@@ -256,8 +256,10 @@ export declare function serializeAccountsYaml(raw: unknown, defaultAccount?: str
256
256
  * Resolve and validate the raw row config. Throws with an actionable message
257
257
  * (in Chinese, since it is what the user and the model both read) when the
258
258
  * account is not fully specified.
259
+ * An explicit account resolves only that card for connection tests, without
260
+ * making named accounts eligible for the single-account environment password.
259
261
  */
260
- export declare function resolveEmailSettings(config: EmailConfig | undefined): ResolvedEmailSettings;
262
+ export declare function resolveEmailSettings(config: EmailConfig | undefined, onlyAccount?: string): ResolvedEmailSettings;
261
263
  /** Every name a `provider:` may legally use, built-ins first. */
262
264
  export declare function providerNames(custom?: Record<string, ServerPreset>): string[];
263
265
  /**
package/lib/config.js CHANGED
@@ -201,8 +201,10 @@ function normalizeAccountForYaml(value) {
201
201
  * Resolve and validate the raw row config. Throws with an actionable message
202
202
  * (in Chinese, since it is what the user and the model both read) when the
203
203
  * account is not fully specified.
204
+ * An explicit account resolves only that card for connection tests, without
205
+ * making named accounts eligible for the single-account environment password.
204
206
  */
205
- export function resolveEmailSettings(config) {
207
+ export function resolveEmailSettings(config, onlyAccount) {
206
208
  const raw = config ?? {};
207
209
  const common = {
208
210
  provider: raw.provider,
@@ -218,6 +220,10 @@ export function resolveEmailSettings(config) {
218
220
  const entries = parsedYaml !== undefined
219
221
  ? parsedYaml.map
220
222
  : (raw.accounts === undefined || Object.keys(raw.accounts).length === 0 ? undefined : raw.accounts);
223
+ const available = entries === undefined ? ['default'] : Object.keys(entries);
224
+ if (onlyAccount !== undefined && !available.includes(onlyAccount)) {
225
+ throw new Error(`未知账号 "${onlyAccount}",可用:${available.join('、')}`);
226
+ }
221
227
  // The custom preset table is read once, here, and never stored on the result:
222
228
  // it is a lookup source for provider names, not part of the resolved config.
223
229
  // A broken preset text degrades to "no custom presets" — the account-level
@@ -237,11 +243,16 @@ export function resolveEmailSettings(config) {
237
243
  }
238
244
  else {
239
245
  for (const [name, acc] of Object.entries(entries)) {
246
+ if (onlyAccount !== undefined && name !== onlyAccount)
247
+ continue;
240
248
  accounts.set(name, resolveAccount(name, common, acc ?? {}, false, providers, known));
241
249
  }
242
250
  }
243
251
  let defaultName;
244
- if (raw.defaultAccount !== undefined && raw.defaultAccount !== '') {
252
+ if (onlyAccount !== undefined) {
253
+ defaultName = onlyAccount;
254
+ }
255
+ else if (raw.defaultAccount !== undefined && raw.defaultAccount !== '') {
245
256
  if (!accounts.has(raw.defaultAccount)) {
246
257
  throw new Error(`dsh-email:defaultAccount "${raw.defaultAccount}" 不存在,可用账号:${[...accounts.keys()].join('、')}`);
247
258
  }
@@ -343,7 +354,7 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
343
354
  };
344
355
  const problems = [];
345
356
  if (user === '')
346
- problems.push(`账号 "${name}" 的 user(邮箱地址)未填写`);
357
+ problems.push(`账号 "${name}" 的邮箱地址未填写`);
347
358
  // An explicit `authKind` outranks the derivation: a tenant that still accepts
348
359
  // an app password for Exchange Online, or a mailbox that worked before the
349
360
  // derivation existed, keeps working instead of being told「尚未登录」.
@@ -358,14 +369,14 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
358
369
  // in the OAuth2 store, and requiring a password would demand a secret
359
370
  // Microsoft no longer accepts for Exchange Online.
360
371
  if (!oauth2 && authPassword === '') {
361
- problems.push(`账号 "${name}" 的 ${authUser === user ? 'password' : 'authPassword'} 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
372
+ problems.push(`账号 "${name}" 的${authUser === user ? '授权码 / 应用专用密码' : '登录账号的密码'}未填写`);
362
373
  }
363
374
  if (imap.host === undefined || imap.host === '')
364
375
  problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${known.join('/')})`);
365
376
  if (smtp.host === undefined || smtp.host === '')
366
377
  problems.push(`账号 "${name}" 的 smtp.host 未填写(同上)`);
367
378
  if (problems.length > 0) {
368
- throw new Error(`dsh-email 未配置:${problems.join(';')}。请在 profile 的 cordis.patch.yml 中覆盖 tool-email 行并重启(见插件 README)`);
379
+ throw new Error(`邮件账号配置不完整:${problems.join(';')}。请在「设置 → 邮件」补全对应账号,再点击「测试连接」。修改会自动保存并立即生效。`);
369
380
  }
370
381
  const clientId = (acc.clientId ?? common.clientId ?? '').trim();
371
382
  return {
@@ -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
@@ -1,4 +1,5 @@
1
1
  import type { EmailConfig } from './config.js';
2
+ export declare const Config: import("@deepseek-ai/schemastery").default;
2
3
  export declare const name = "tool-email";
3
4
  export declare const inject: string[];
4
5
  export type Config = EmailConfig;
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/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
- const settingsScope = ctx.settings.register(SETTINGS_NAMESPACE, EmailSettingsSchema, {
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
- const descriptor = (ctx.settings.describe?.() ?? []).find(row => row.ns === SETTINGS_NAMESPACE);
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: z<Schemastery.ObjectS<{
11
- provider: z<string, string>;
12
- user: z<string, string>;
13
- password: z<string, string>;
14
- inboxFolder: z<string, string>;
15
- sendApproval: z<boolean, boolean>;
16
- maxBodyChars: z<number, number>;
17
- downloadDir: z<string, string>;
18
- accountsYaml: z<string, string>;
19
- serverPresets: z<string, string>;
20
- imap: z<Schemastery.ObjectS<{
21
- host: z<string, string>;
22
- port: z<number, number>;
23
- secure: z<boolean, boolean>;
24
- }>, Schemastery.ObjectT<{
25
- host: z<string, string>;
26
- port: z<number, number>;
27
- secure: z<boolean, boolean>;
28
- }>>;
29
- smtp: z<Schemastery.ObjectS<{
30
- host: z<string, string>;
31
- port: z<number, number>;
32
- secure: z<boolean, boolean>;
33
- }>, Schemastery.ObjectT<{
34
- host: z<string, string>;
35
- port: z<number, number>;
36
- secure: z<boolean, boolean>;
37
- }>>;
38
- }>, Schemastery.ObjectT<{
39
- provider: z<string, string>;
40
- user: z<string, string>;
41
- password: z<string, string>;
42
- inboxFolder: z<string, string>;
43
- sendApproval: z<boolean, boolean>;
44
- maxBodyChars: z<number, number>;
45
- downloadDir: z<string, string>;
46
- accountsYaml: z<string, string>;
47
- serverPresets: z<string, string>;
48
- imap: z<Schemastery.ObjectS<{
49
- host: z<string, string>;
50
- port: z<number, number>;
51
- secure: z<boolean, boolean>;
52
- }>, Schemastery.ObjectT<{
53
- host: z<string, string>;
54
- port: z<number, number>;
55
- secure: z<boolean, boolean>;
56
- }>>;
57
- smtp: z<Schemastery.ObjectS<{
58
- host: z<string, string>;
59
- port: z<number, number>;
60
- secure: z<boolean, boolean>;
61
- }>, Schemastery.ObjectT<{
62
- host: z<string, string>;
63
- port: z<number, number>;
64
- secure: z<boolean, boolean>;
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 === SETTINGS_NAMESPACE);
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 === SETTINGS_NAMESPACE);
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
- await this.ctx.settings.replace(SETTINGS_NAMESPACE, value, expectedRevision);
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
@@ -841,12 +855,12 @@ export class EmailSettingsBackend {
841
855
  // does for the stored settings (it never enters the resolved fingerprint).
842
856
  const draft = toEmailConfig(value, null);
843
857
  const presets = value?.serverPresets ?? this.scope.get()?.serverPresets;
858
+ const requested = typeof accountName === 'string' && accountName.trim() !== '' ? accountName.trim() : '';
844
859
  const settings = resolveEmailSettings({
845
- ...this.rowConfig,
860
+ ...this.currentConfig,
846
861
  ...draft,
847
862
  ...(typeof presets === 'string' ? { serverPresets: presets } : {}),
848
- });
849
- const requested = typeof accountName === 'string' && accountName.trim() !== '' ? accountName.trim() : '';
863
+ }, requested || undefined);
850
864
  const available = [...settings.accounts.keys()];
851
865
  const name = requested !== '' ? requested : settings.defaultAccount;
852
866
  const cfg = settings.accounts.get(name);
@@ -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.rowConfig.defaultAccount, tokenLookup());
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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "DSH 邮件插件:IMAP/SMTP 收发搜索、回复转发、附件与整理,多账号卡片设置页、Outlook OAuth2 登录与发信审批。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -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",