dsh-email 0.13.2 → 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/README.en.md CHANGED
@@ -37,6 +37,8 @@ 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
+
40
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.
41
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.
42
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.
@@ -45,6 +47,8 @@ Example:
45
47
  - **0.10.8 and earlier**: see [CHANGELOG.md](CHANGELOG.md).
46
48
  ## Compatibility
47
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
+
48
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.
49
53
 
50
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.
@@ -53,7 +57,7 @@ Co-load verification was performed on 2026-09-16 with official source builds of
53
57
 
54
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.
55
59
 
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.
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.
57
61
 
58
62
  ## Installation
59
63
 
@@ -74,7 +78,7 @@ The whole settings page follows DSH's light and dark themes: every panel style r
74
78
 
75
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.
76
80
 
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.
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.
78
82
 
79
83
  ## Uninstall
80
84
 
package/README.md CHANGED
@@ -45,6 +45,8 @@ 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
+
48
50
  - **0.13.2(2026-09-21)**:同名附件优先按真实 MIME 分段编号下载;旧解析元数据按一对一匹配,避免多个序号都取到第一个同名文件。保留正文分段下载和附件索引缓存,新增文件字节级回归,268 项测试通过。
49
51
  - **0.13.1(2026-09-19)**:内置一份社区公共客户端注册(感谢 [gurio-wine](https://github.com/gurio-wine)),Outlook / Exchange Online 的 OAuth2 登录开箱即用;想用自己的应用仍可填 `clientId` 覆盖,设置页会显示当前生效的是哪个应用。测试 264 项。
50
52
  - **0.13.0(2026-09-18)**:修复长正文截断成空、`email_watch` 永久漏报新邮件、附件缓存跨 UIDVALIDITY 失效;10 个工具声明超时;读信/搜索只下正文分段;搜索回退标明扫描口径。测试 262 项。
@@ -53,6 +55,8 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
53
55
  - **0.10.8 及更早**:见 [CHANGELOG.md](CHANGELOG.md)。
54
56
  ## 兼容性
55
57
 
58
+ 当前验证基线为官方源码构建的 Harness **0.1.7-alpha.2**(2026-09-23,含本地工具调度器 `Symbol.for` 修复)。18 个插件共同加载;邮件设置已通过旧配置迁移、真实网页编辑与自动保存、刷新恢复、修订冲突和重启持久化检查。本轮使用隔离测试账号,未连接真实邮箱或发送邮件。
59
+
56
60
  2026-09-21:当前发布包经官方 CLI 安装到隔离 profile,在源码构建的 Harness `0.1.6-alpha.2` 上与另外两个下载量前三插件共同加载,18 个插件工具注册正常;日历/邮件配置自检、PPT 主题查询和 17 行表格生成通过。测试本体基于官方 alpha.2 发布提交,另含工具调度器 `Symbol.for` 修复(`93badd88`)。本轮未连接真实邮箱或日历服务。
57
61
 
58
62
  2026-09-16 曾在官方源码构建的 Harness `0.1.5-rc.2` 和 `0.1.6-alpha.1` 上完成同载验证:18 个组件与 ModLens 同载,工具 schema、技能注册及离线只读调用检查通过。
@@ -61,7 +65,7 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
61
65
 
62
66
  2026-09-10,npm `dsh-email@0.10.6` 曾通过真实 QQ 邮箱目录、列表、读取和搜索,以及设置页“测试连接”“保存并应用”检查;授权码留空时能继续使用 `DSH_EMAIL_PASSWORD`。独立 SMTP 登录认证也已通过。此次复验未连接真实邮箱,未发送、修改或删除邮件。
63
67
 
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 仍在快速迭代,上述版本是实测基线。
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 仍在快速迭代,上述版本是实测基线。
65
69
 
66
70
  ## 安装
67
71
 
@@ -82,7 +86,7 @@ dsh plugin --profile web add dsh-email
82
86
 
83
87
  多账号可以在设置页可视化编辑:账号卡片支持增删改账号、改名、设默认、按账号名单独「测试连接」;没填完的账号不阻断保存,只标一个「未完成」。卡片改动即时防抖落盘,不再有"先写 YAML 文本、再点一次保存"这一步;版本冲突(别处也改了设置)会自动重基后重存一次,而不是拿旧版本号反复失败。保存卡片时,已存的授权码默认保持(密码栏留空 = 不变,填内容 = 覆盖);YAML 里的注释尽量原地保留,实在保不住时会明确提示。改名走的是原地改键,授权码与高级键一并保留,且不允许改成已有账号名(那会顶掉另一个账号)。账号自己手写的 imap/smtp 端点只在**服务商真的换了**时才清洗——运行时以账号自己的 host 优先,普通保存不会悄悄改动连接目标。
84
88
 
85
- 设置页保存的值存在 `settings.yaml` 的 `dsh-email` 命名空间里,覆盖 YAML 的默认账号配置。授权码字段标记为 secret,但填写后保存仍会写入本机配置文件。单账号如需避免保存授权码,可设置 `DSH_EMAIL_PASSWORD` 并将授权码栏留空;环境变量不会被复制进设置文件。
89
+ Harness 0.1.7 中,设置页直接保存到当前 profile 的 `tool-email` 配置行,修改立即生效。使用默认插件行的旧安装会自动导入 `settings.yaml` 或 `settings.yaml.imported` 中的 `dsh-email` 设置;已有 profile 配置优先,原文件保留且只迁移一次,无需重新输入账号。旧版宿主仍使用原 settings 存储。授权码字段标记为 secret,但填写后保存仍会写入本机配置文件。单账号如需避免保存授权码,可设置 `DSH_EMAIL_PASSWORD` 并将授权码栏留空;环境变量不会被复制进设置文件。
86
90
 
87
91
  ## 卸载
88
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
- 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
 
@@ -245,8 +246,8 @@ var UI = {
245
246
  // 导航 / 段标题
246
247
  "nav.title": "邮件 (dsh-email)",
247
248
  "section.title": "邮件",
248
- "section.intro": "在这里配置邮箱账号,即可使用 10 个 email_* 工具。每个账号只记服务商与凭证,端点在连接时从服务器预设展开;改动自动保存、立即生效。",
249
- "section.readonly": "当前 settings 存储是只读的,只能查看不能保存。",
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": "附件下载目录(默认 $DSH_HOME/email-downloads)",
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}」会在保存后从 YAML 里移除,已存密码一并丢失。",
303
- "card.nameLabel": "账号名(工具调用的 account 参数)",
304
- "card.nameHint": "不能叫 defaultAccount(该键保留给默认账号)。改名会删旧键、建新键。",
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": "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.",
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 $DSH_HOME/email-downloads)",
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 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.",
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
@@ -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
@@ -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.rowConfig,
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.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.0",
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",