dsh-email 0.14.3 → 0.14.5

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.5(2026-10-01)
4
+
5
+ - 修复 [#20](https://github.com/STARDUSTLC666/dsh-email/issues/20):具名账号的服务商预设不再被顶层共享服务器遮蔽;Gmail 不再因继承 Outlook 端点被误判为 OAuth2。保留账号显式端点、传输超时和旧单账号继承规则。
6
+ - 账号映射转换为卡片时沿用相同端点规则;新版设置服务保存卡片时排除表单派生的共享 imap/smtp,避免修改 Outlook 后改变其他账号的连接。
7
+ - 连接失败显示账号、实际 IMAP 主机与端口;密码赋值及服务端回显的已知密码会脱敏,短密码不会破坏普通错误文字。
8
+ - 修复顶层 senderName / authUser / authPassword 未传入单账号解析的问题。
9
+ - 空地址卡片点击测试时先提示填写邮箱地址,不再测试隐藏表单中的旧凭据;保存后及时更新 Outlook 内置应用 ID 和独立登录密码的提示。
10
+ - Windows 下新增十项行为回归,整套邮件 295 项通过;官方 Harness 0.2.0-rc.2 源码构建及 18 插件同载检查通过。原生桌面已实测新增卡片、服务商切换、自动保存与删除;最后的字段提示修正有组件行为回归,尚未再次实机操作。未验证真实邮箱登录、OAuth2 授权或发信成功。
11
+
12
+ ## 0.14.4(2026-09-29)
13
+
14
+ - 修复:SMTP 发送改为**每次发送独占连接**,取消(AbortSignal)时只销毁本次连接的 socket 并立即拒绝,不再影响同账号的其它发送,也不会在取消后仍把邮件发出去。
15
+ - 修复:OAuth2 令牌刷新与取消的竞态——取消后即使令牌刷新成功也不再继续发送。
16
+ - 测试:修正 TLS fixture 的两处握手缺陷(STARTTLS 明文阶段未发送 220;取消用例把 greeting 压住导致走不到 TLS 升级),TLS 与取消回归现可稳定通过(285 项)。
3
17
  ## 0.14.3(2026-09-28)
4
18
 
5
19
  - 兼容验证更新到 Harness 0.2.0-rc.1:282 项邮件测试与 18 插件共同加载检查通过。
package/README.en.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # dsh-email
2
2
 
3
- ## 0.14.2 update (2026-09-28)
3
+ ## 0.14.5 update (2026-10-01)
4
4
 
5
- Fixes saved blank-host defaults overriding Outlook's 587/STARTTLS preset, including legacy settings migrated to Harness 0.1.7. Explicit custom hosts, ports and TLS choices remain effective. Thanks to [SenkjM for the reproduction and proposed fix in #17 / #18](https://github.com/STARDUSTLC666/dsh-email/pull/18).
5
+ Fixes [#20](https://github.com/STARDUSTLC666/dsh-email/issues/20): a named account's provider selects its own servers, so Gmail no longer inherits shared Outlook endpoints or becomes an OAuth2 account. Card saves exclude form-derived shared endpoints. Explicit account endpoints and legacy single-account configuration remain supported; top-level sender aliases and login credentials now take effect. Connection failures identify the selected account and actual IMAP host and port, with echoed credentials redacted.
6
6
 
7
- Validation host: Harness `0.2.0-rc.1` built from official sources (commit `407e65c8`) with Node `24.16.0` on 2026-09-28. All 282 plugin tests pass in an isolated environment; all 18 plugins mount together in one host registering 10 tools, with tool schemas and health-check contracts passing. No live ports or external services were exercised in this round.
7
+ An empty card asks for its email address before testing. Outlook's built-in application ID updates from the saved snapshot, avoiding a false missing-ID warning. Stored separate login passwords are acknowledged without exposing their values.
8
+
9
+ Validation host: Harness `0.2.0-rc.2` built from the official release tag (commit `639ed01539`), Windows and Node `24.16.0`. All 295 email tests pass; all 18 plugins mount together, with 10 email tools. Native Desktop checks covered adding a card, switching Outlook/Gmail, automatic saving and deletion. The final field-hint fixes have component behavior regressions but have not been retested in the native UI. Successful real-mailbox login, OAuth2 authorization and sending remain unverified.
8
10
 
9
11
  ![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)
10
12
 
@@ -53,11 +55,7 @@ Example:
53
55
  - **0.10.8 and earlier**: see [CHANGELOG.md](CHANGELOG.md).
54
56
  ## Compatibility
55
57
 
56
- The current baseline is official-source Harness **0.2.0-rc.1** (2026-09-28, with a local `Symbol.for` tool-scheduler fix). All 18 plugins load together. Settings coverage includes legacy migration, real Web UI editing and automatic saving, refresh, revision conflicts and restart persistence with isolated fixture accounts. No live mailbox connection or sending was exercised in this round.
57
-
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.
59
-
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.
58
+ The current baseline is Harness **0.2.0-rc.2** built from its official release tag on 2026-09-30. All 18 plugins mount together with 99 tools and 35 skills; registration and output checks pass. New email regressions cover provider isolation, card saves, endpoint diagnostics, sender aliases and editor hints. The native UI scope and remaining live-service checks are described above.
61
59
 
62
60
  **0.11.0 co-load verification (2026-09-18, locally built Harness `0.1.5-rc.2`, `web` profile)**: the plugin mounted without errors; the settings route answered GET with 200 and no `raw` field in the response; a `text/plain` POST was refused with **415**, proving the same-origin guard holds in the real host; under a `application/json` POST the card projection was correct, and an account pinning `authKind: password` reported both `authKindDeclared` and `authKind` as `password`; the panel actually rendered account cards, the eight provider presets with localized labels, the three-way authentication selector, the application (client) ID field with its hint, the missing-ID warning banner (so `--dsw-alias-state-warn-primary` is genuinely defined in the real host) and the Sign in with Microsoft button; the browser console was clean; save worked end to end, and afterwards `accountsYaml` was restored to empty with the original account intact. Offline tests: 231 green. **Still not done**: an end-to-end OAuth2 run against a real Outlook tenant (the device-code flow needs a human to authorize in a browser) and real sending; the `clientId` paths are covered only against a fake authority. Uses the `cordis.patch.yml` + `dsh.bundle.patch` bundle model. Node requirements are 22.19 or later within 22.x, or 24 or later. Live external-service workflows require separate configuration and validation.
63
61
 
package/README.md CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  # dsh-email
4
4
 
5
- ## 0.14.2 更新(2026-09-28)
5
+ ## 0.14.5 更新(2026-10-01)
6
6
 
7
- 修复保存过的空服务器默认值覆盖 Outlook 预设的问题:恢复 `587 / STARTTLS`,并覆盖 Harness 0.1.7 的旧配置迁移路径。明确设置的自定义主机、端口和 TLS 仍然生效。感谢 [SenkjM 在 #17 / #18 提供复现和修复方向](https://github.com/STARDUSTLC666/dsh-email/pull/18)。
7
+ 修复 [#20](https://github.com/STARDUSTLC666/dsh-email/issues/20):具名账号选择自己的服务商后,使用该服务商的服务器;Gmail 不再继承共享 Outlook 端点并被误判为 OAuth2。账号卡片保存不再把默认账号端点写回共享配置。保留显式账号端点与旧单账号配置,同时修复顶层发送别名和登录凭据未生效的问题。连接失败显示所选账号及实际 IMAP 主机、端口,服务端回显的密码继续脱敏。
8
8
 
9
- 验证宿主:官方源码构建的 Harness `0.2.0-rc.1`(commit `407e65c8`)+ Node `24.16.0`(2026-09-28)。282 项插件测试在隔离环境全部通过;同一个宿主里 18 个插件共同加载,注册 10 个工具,工具 schema 与健康检查契约通过。本轮未启用真实端口与外部服务。
9
+ 空地址卡片点击测试时先提示填写邮箱地址;切换到 Outlook 后,内置应用 ID 提示随保存结果更新,不再误报缺失。独立登录密码只显示是否已保存,仍不回填明文。
10
+
11
+ 验证宿主:官方源码构建的 Harness `0.2.0-rc.2`(commit `639ed01539`)+ Windows / Node `24.16.0`。295 项邮件测试通过;18 个插件共同加载,邮件注册 10 个工具。原生桌面已实测新增卡片、Outlook/Gmail 切换、自动保存和删除。最后的字段提示修正通过组件行为回归,尚未再次实机操作;未验证真实邮箱登录、OAuth2 授权或发信成功。
10
12
 
11
13
  > **让 agent 协助处理邮件**:收发、搜索、回复转发、附件、邮件整理与新邮件提醒,支持八种常见邮箱服务预设。
12
14
 
@@ -61,16 +63,10 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
61
63
  - **0.10.8 及更早**:见 [CHANGELOG.md](CHANGELOG.md)。
62
64
  ## 兼容性
63
65
 
64
- 当前验证基线为官方源码构建的 Harness **0.2.0-rc.1**(2026-09-28,含本地工具调度器 `Symbol.for` 修复)。18 个插件共同加载;邮件设置覆盖旧配置迁移、真实网页编辑与自动保存、刷新恢复、修订冲突和重启持久化检查。本轮使用隔离测试账号,未连接真实邮箱或发送邮件。
65
-
66
- 2026-09-21:当前发布包经官方 CLI 安装到隔离 profile,在源码构建的 Harness `0.1.6-alpha.2` 上与另外两个下载量前三插件共同加载,18 个插件工具注册正常;日历/邮件配置自检、PPT 主题查询和 17 行表格生成通过。测试本体基于官方 alpha.2 发布提交,另含工具调度器 `Symbol.for` 修复(`93badd88`)。本轮未连接真实邮箱或日历服务。
67
-
68
- 2026-09-16 曾在官方源码构建的 Harness `0.1.5-rc.2` 和 `0.1.6-alpha.1` 上完成同载验证:18 个组件与 ModLens 同载,工具 schema、技能注册及离线只读调用检查通过。
66
+ 当前验证基线为官方发布标签源码构建的 Harness **0.2.0-rc.2**(2026-09-30)。18 个插件共同加载,99 个工具与 35 个技能的注册及输出检查通过;邮件新增多服务商隔离、卡片保存、端点错误提示、发送别名与编辑提示回归。桌面操作范围与尚未完成的真实服务验证见上文。
69
67
 
70
68
  **0.11.0 的同载验证(2026-09-18,本地构建的 Harness `0.1.5-rc.2`,`web` profile)**:插件挂载无报错;设置路由 GET 返回 200 且响应中已无 `raw` 字段;用 `text/plain` 发 POST 被 **415** 拒绝(同源守卫在真实宿主下生效);`application/json` 的 POST 下卡片投影正确,账号钉住 `authKind: password` 后 `authKindDeclared` 与 `authKind` 均为 `password`;设置面板实际渲染出账号卡片、8 个服务商预设的中文下拉、「认证方式」三态选择器、「应用(客户端)ID」输入格与提示、未填 ID 时的警示条(说明 `--dsw-alias-state-warn-primary` 在真实宿主下确有定义)与「登录 Microsoft 账号」按钮;浏览器控制台无报错;save 全链路可用,验证结束后已把 `accountsYaml` 还原为空、原有账号恢复。离线测试 231 项全绿。**仍未做**:真实 Outlook 租户的 OAuth2 端到端(设备码流程要真人在浏览器完成授权)与真实发信未测,`clientId` 相关路径目前只有假 authority 的用例覆盖。采用 `cordis.patch.yml` + `dsh.bundle.patch` 组合包模型。Node 要求为 22.19 及以上的 22.x,或 24 及以上。外部服务的实际业务操作需按各组件配置单独验证。
71
69
 
72
- 2026-09-10,npm `dsh-email@0.10.6` 曾通过真实 QQ 邮箱目录、列表、读取和搜索,以及设置页“测试连接”“保存并应用”检查;授权码留空时能继续使用 `DSH_EMAIL_PASSWORD`。独立 SMTP 登录认证也已通过。此次复验未连接真实邮箱,未发送、修改或删除邮件。
73
-
74
70
  遵循官方[插件打包与安装要求](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 仍在快速迭代,上述版本是实测基线。
75
71
 
76
72
  ## 安装
@@ -139,7 +135,7 @@ dsh plugin --profile web remove dsh-email
139
135
  downloadDir: E:/attachments # 可选,默认 $DSH_HOME/email-downloads
140
136
  ```
141
137
 
142
- 顶层的 `provider`/`user`/`password`/`imap`/`smtp`/`inboxFolder` 仍然可用,作为各账号的共享默认值(v0.1 单账号写法完全兼容)。
138
+ 顶层的 `provider`/`user`/`password`/`imap`/`smtp`/`inboxFolder` 仍兼容旧单账号写法。具名账号声明自己的 `provider` 时,服务器取该预设,账号自己的 `imap`/`smtp` 显式覆盖优先;未声明服务商的账号继续继承顶层服务器。共享连接超时仍可生效。
143
139
 
144
140
  想在多个账号之间复用同一套连接端点,可以用 `serverPresets` 自定义服务商预设(YAML 映射,键 = 预设名,值含可选的 `label` 与 `imap`/`smtp`):
145
141
 
package/lib/client.js CHANGED
@@ -295,6 +295,7 @@ var UI = {
295
295
  "card.testing": "测试中…",
296
296
  "card.testOk": "连接成功({ms} ms)· {host}:{port}",
297
297
  "card.testFailed": "连接失败:{message}",
298
+ "card.testMissingAddress": "请先填写这个账号的邮箱地址,再测试连接。",
298
299
  "card.pickDefault": "配置了多个账号,请设置默认账号:",
299
300
  "card.errNoName": "有账号还没填账号名,这份改动先不保存。",
300
301
  "card.errNoProvider": "有账号还没选服务商,这份改动先不保存。",
@@ -475,6 +476,7 @@ var UI = {
475
476
  "card.testing": "Testing…",
476
477
  "card.testOk": "Connected ({ms} ms) · {host}:{port}",
477
478
  "card.testFailed": "Connection failed: {message}",
479
+ "card.testMissingAddress": "Enter this account's email address before testing the connection.",
478
480
  "card.pickDefault": "Several accounts are configured; choose the default account:",
479
481
  "card.errNoName": "An account has no name yet; this change will not be saved.",
480
482
  "card.errNoProvider": "An account has no provider selected; this change will not be saved.",
@@ -1154,6 +1156,9 @@ function oauthViewOf(draft, cards) {
1154
1156
  oauthState: sameProvider && liveState !== "" ? liveState : draftState,
1155
1157
  oauthUser: sameProvider && live && typeof live.oauthUser === "string" ? live.oauthUser : String(draft.oauthUser || ""),
1156
1158
  hasPassword: live !== null && live.hasPassword === true,
1159
+ hasAuthPassword: live !== null && live.hasAuthPassword === true,
1160
+ defaultClientId: sameProvider && typeof live.oauthDefaultClientId === "string"
1161
+ ? live.oauthDefaultClientId : String(draft.defaultClientId || ""),
1157
1162
  };
1158
1163
  }
1159
1164
 
@@ -1195,7 +1200,7 @@ function OauthLoginPanel(props) {
1195
1200
  // (公共客户端 ID 每次都随请求发出),因此可以直接回填、直接编辑。留空不是
1196
1201
  // 「没配置」:插件自带一份社区注册,所以这里必须说清生效的到底是哪个应用 ——
1197
1202
  // 同意屏上写的名字不是账号主人自己的,这件事不该等点了登录才发现。
1198
- const builtInClientId = String(draft.defaultClientId || "").trim();
1203
+ const builtInClientId = String(view.defaultClientId ?? draft.defaultClientId ?? "").trim();
1199
1204
  const clientIdField = () => h("div", { className: "dshe-field dshe-oauth-client" }, [
1200
1205
  h("label", null, t("oauth.clientIdLabel")),
1201
1206
  h("input", {
@@ -1595,6 +1600,13 @@ function AccountCardsEditor(props) {
1595
1600
 
1596
1601
  /** 传的是卡片上的「当前账号名」(可能刚改过名),行内提示按它归类。 */
1597
1602
  const testCard = async (draftName) => {
1603
+ // An empty card must not fall back to credentials in the hidden legacy form.
1604
+ if (String(draftsRef.current[draftName]?.user || "").trim() === "") {
1605
+ setRowStatus((cur) => Object.assign({}, cur, {
1606
+ [draftName]: { kind: "error", text: t("card.testMissingAddress") },
1607
+ }));
1608
+ return;
1609
+ }
1598
1610
  setBusy(draftName);
1599
1611
  setRowStatus((cur) => Object.assign({}, cur, { [draftName]: { kind: "busy", text: t("card.testing") } }));
1600
1612
  try {
package/lib/config.d.ts CHANGED
@@ -280,6 +280,8 @@ export declare function providerNames(custom?: Record<string, ServerPreset>): st
280
280
  * this provider name be written?", where a broken table can only mean "no".
281
281
  */
282
282
  export declare function presetNamesIn(text: string | undefined): string[];
283
+ /** Materialize shared account defaults without replacing a named provider's servers. */
284
+ export declare function mergeAccountDefaults(common: AccountConfig, account?: AccountConfig): AccountConfig;
283
285
  /** v0.1-compatible wrapper: resolve the single (or default) account. */
284
286
  export declare function resolveEmailConfig(config: EmailConfig | undefined): ResolvedEmailConfig;
285
287
  export declare function clampInt(value: unknown, fallback: number, min: number, max: number): number;
package/lib/config.js CHANGED
@@ -228,6 +228,9 @@ export function resolveEmailSettings(config, onlyAccount) {
228
228
  provider: raw.provider,
229
229
  user: raw.user,
230
230
  password: raw.password,
231
+ senderName: raw.senderName,
232
+ authUser: raw.authUser,
233
+ authPassword: raw.authPassword,
231
234
  clientId: raw.clientId,
232
235
  authKind: raw.authKind,
233
236
  imap: raw.imap,
@@ -341,7 +344,24 @@ export function presetNamesIn(text) {
341
344
  return [];
342
345
  }
343
346
  }
344
- /** Merge one account over the shared shorthand and validate it. */
347
+ /** Materialize shared account defaults without replacing a named provider's servers. */
348
+ export function mergeAccountDefaults(common, account = {}) {
349
+ const acc = account ?? {};
350
+ const ownProvider = acc.provider !== undefined && acc.provider !== '';
351
+ // A named provider selects its own servers; shared connection budgets still apply.
352
+ const sharedImap = ownProvider ? {
353
+ ...(common.imap?.connectionTimeoutMs !== undefined ? { connectionTimeoutMs: common.imap.connectionTimeoutMs } : {}),
354
+ ...(common.imap?.socketTimeoutMs !== undefined ? { socketTimeoutMs: common.imap.socketTimeoutMs } : {}),
355
+ } : common.imap && normalizeSettingsEndpoint(common.imap, 'imap');
356
+ const sharedSmtp = !ownProvider && common.smtp ? normalizeSettingsEndpoint(common.smtp, 'smtp') : undefined;
357
+ const imap = { ...sharedImap, ...(acc.imap && normalizeSettingsEndpoint(acc.imap, 'imap')) };
358
+ const smtp = { ...sharedSmtp, ...(acc.smtp && normalizeSettingsEndpoint(acc.smtp, 'smtp')) };
359
+ return { ...common, ...acc,
360
+ imap: Object.keys(imap).length > 0 ? imap : undefined,
361
+ smtp: Object.keys(smtp).length > 0 ? smtp : undefined,
362
+ };
363
+ }
364
+ /** Merge one account over shared defaults and its selected provider, then validate it. */
345
365
  function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
346
366
  const requested = acc.provider ?? common.provider;
347
367
  const preset = requested === undefined ? undefined : providers[requested];
@@ -358,21 +378,20 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
358
378
  const authUser = (acc.authUser ?? common.authUser ?? '').trim() || user;
359
379
  const authPassword = (acc.authPassword ?? common.authPassword) || password;
360
380
  const senderName = (acc.senderName ?? common.senderName ?? '').trim();
361
- const accountImap = acc.imap && normalizeSettingsEndpoint(acc.imap, 'imap');
362
- const sharedImap = common.imap && normalizeSettingsEndpoint(common.imap, 'imap');
363
- const accountSmtp = acc.smtp && normalizeSettingsEndpoint(acc.smtp, 'smtp');
364
- const sharedSmtp = common.smtp && normalizeSettingsEndpoint(common.smtp, 'smtp');
381
+ const merged = mergeAccountDefaults(common, acc);
382
+ const accountImap = merged.imap && normalizeSettingsEndpoint(merged.imap, 'imap');
383
+ const accountSmtp = merged.smtp && normalizeSettingsEndpoint(merged.smtp, 'smtp');
365
384
  const imap = {
366
- host: accountImap?.host ?? sharedImap?.host ?? preset?.imap.host,
367
- port: accountImap?.port ?? sharedImap?.port ?? preset?.imap.port,
368
- secure: accountImap?.secure ?? sharedImap?.secure ?? preset?.imap.secure,
369
- connectionTimeoutMs: accountImap?.connectionTimeoutMs ?? sharedImap?.connectionTimeoutMs,
370
- socketTimeoutMs: accountImap?.socketTimeoutMs ?? sharedImap?.socketTimeoutMs,
385
+ host: accountImap?.host ?? preset?.imap.host,
386
+ port: accountImap?.port ?? preset?.imap.port,
387
+ secure: accountImap?.secure ?? preset?.imap.secure,
388
+ connectionTimeoutMs: accountImap?.connectionTimeoutMs,
389
+ socketTimeoutMs: accountImap?.socketTimeoutMs,
371
390
  };
372
391
  const smtp = {
373
- host: accountSmtp?.host ?? sharedSmtp?.host ?? preset?.smtp.host,
374
- port: accountSmtp?.port ?? sharedSmtp?.port ?? preset?.smtp.port,
375
- secure: accountSmtp?.secure ?? sharedSmtp?.secure ?? preset?.smtp.secure,
392
+ host: accountSmtp?.host ?? preset?.smtp.host,
393
+ port: accountSmtp?.port ?? preset?.smtp.port,
394
+ secure: accountSmtp?.secure ?? preset?.smtp.secure,
376
395
  };
377
396
  const problems = [];
378
397
  if (user === '')
@@ -15,10 +15,8 @@ export declare function messageOf(error: unknown, fallback: string): string;
15
15
  * messages are rendered in the settings panel, returned by the mail tools, and
16
16
  * pasted into bug reports.
17
17
  *
18
- * Two shapes are masked: a JWT (three base64url segments, which is what every
19
- * OAuth2 access token looks like) and a long base64 run (the quoted XOAUTH2
20
- * blob). The replacement keeps the length so a report still says how big the
21
- * thing was, without saying what it was.
18
+ * Mask JWTs, long base64 authentication blobs and credential assignments.
19
+ * Token replacements retain their length for diagnostics.
22
20
  */
23
21
  export declare function redactCredentials(text: string): string;
24
22
  /** The IMAP auth shape imapflow accepts: a password, or an OAuth2 access token. */
@@ -108,8 +106,7 @@ export declare function extractMessageIds(source: Buffer): {
108
106
  */
109
107
  export declare function buildReplyMessage(original: OriginalDigest, mode: EmailReplyMode, selfAddress: string | readonly string[], text: string, forwardTo?: string): BuiltReply;
110
108
  /**
111
- * One mailbox pool for the whole plugin: pooled IMAP connections per
112
- * account plus pooled SMTP transporters, with idle sweep and error eviction.
109
+ * One mailbox pool for the whole plugin: pooled IMAP connections per account.
113
110
  */
114
111
  export declare class EmailPool {
115
112
  private readonly settings;
@@ -155,14 +152,13 @@ export declare class EmailPool {
155
152
  startIdleSweep(): void;
156
153
  dispose(): void;
157
154
  /**
158
- * A pooled transporter for one account. The token is captured when the
159
- * transporter is built; an OAuth2 token that turns out to be stale is
160
- * re-minted in sendMail, which rebuilds the transporter.
155
+ * Create an unpooled sender with its own socket. The socket callback exposes
156
+ * only the public SMTP transport hook to the cancellation owner.
161
157
  */
162
158
  private transporter;
163
- private dropTransporter;
164
159
  /**
165
- * Send through the pooled transporter while making cancellation close it.
160
+ * Give each send its own SMTP connection so cancellation can close that
161
+ * operation without closing another send for the same account.
166
162
  *
167
163
  * An OAuth2 transporter carries a token that was minted when it was built,
168
164
  * so a rejection is retried once against a freshly built one (and a fresh
@@ -1,5 +1,6 @@
1
1
  import { ImapFlow } from 'imapflow';
2
2
  import nodemailer from 'nodemailer';
3
+ import { createConnection } from 'node:net';
3
4
  import { mkdir, stat, writeFile } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
5
6
  import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
@@ -23,13 +24,12 @@ export function messageOf(error, fallback) {
23
24
  * messages are rendered in the settings panel, returned by the mail tools, and
24
25
  * pasted into bug reports.
25
26
  *
26
- * Two shapes are masked: a JWT (three base64url segments, which is what every
27
- * OAuth2 access token looks like) and a long base64 run (the quoted XOAUTH2
28
- * blob). The replacement keeps the length so a report still says how big the
29
- * thing was, without saying what it was.
27
+ * Mask JWTs, long base64 authentication blobs and credential assignments.
28
+ * Token replacements retain their length for diagnostics.
30
29
  */
31
30
  export function redactCredentials(text) {
32
31
  return text
32
+ .replace(/(["']?(?:password|passwd|authPassword|secret|api[_-]?key)["']?)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s;,)]+)/gi, '$1$2<已隐去的凭据>')
33
33
  .replace(/[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}/g, match => `<已隐去 ${match.length} 字符的令牌>`)
34
34
  .replace(/[A-Za-z0-9+/]{40,}={0,2}/g, match => `<已隐去 ${match.length} 字符的凭据>`);
35
35
  }
@@ -293,8 +293,7 @@ const READ_CACHE_TTL_MS = 10 * 60 * 1000;
293
293
  /** Folder names change rarely; a short TTL keeps email_folders off the wire. */
294
294
  const FOLDER_CACHE_TTL_MS = 60 * 1000;
295
295
  /**
296
- * One mailbox pool for the whole plugin: pooled IMAP connections per
297
- * account plus pooled SMTP transporters, with idle sweep and error eviction.
296
+ * One mailbox pool for the whole plugin: pooled IMAP connections per account.
298
297
  */
299
298
  export class EmailPool {
300
299
  settings;
@@ -566,41 +565,64 @@ export class EmailPool {
566
565
  this.idleTimer = undefined;
567
566
  for (const name of [...this.imaps.keys()])
568
567
  void this.evictImap(name);
569
- for (const transporter of this.smtps.values())
568
+ for (const [transporter, socket] of this.smtps) {
569
+ socket?.destroy();
570
570
  transporter.close();
571
+ }
571
572
  this.smtps.clear();
572
573
  }
573
574
  /**
574
- * A pooled transporter for one account. The token is captured when the
575
- * transporter is built; an OAuth2 token that turns out to be stale is
576
- * re-minted in sendMail, which rebuilds the transporter.
575
+ * Create an unpooled sender with its own socket. The socket callback exposes
576
+ * only the public SMTP transport hook to the cancellation owner.
577
577
  */
578
- transporter(name, cfg, accessToken) {
579
- let t = this.smtps.get(name);
580
- if (t === undefined) {
581
- t = nodemailer.createTransport({
582
- pool: true,
583
- host: cfg.smtp.host,
584
- port: cfg.smtp.port,
585
- secure: cfg.smtp.secure,
586
- auth: smtpAuthOf(cfg, accessToken),
587
- connectionTimeout: 30000,
588
- greetingTimeout: 10000,
589
- socketTimeout: 60000,
590
- maxConnections: 2,
591
- maxMessages: 50,
592
- });
593
- this.smtps.set(name, t);
594
- }
595
- return t;
596
- }
597
- dropTransporter(name, transporter) {
598
- if (this.smtps.get(name) === transporter)
599
- this.smtps.delete(name);
600
- transporter.close();
578
+ transporter(name, cfg, accessToken, signal, onSocket) {
579
+ const transporter = nodemailer.createTransport({
580
+ host: cfg.smtp.host,
581
+ port: cfg.smtp.port,
582
+ secure: cfg.smtp.secure,
583
+ auth: smtpAuthOf(cfg, accessToken),
584
+ connectionTimeout: 30000,
585
+ greetingTimeout: 10000,
586
+ socketTimeout: 60000,
587
+ getSocket: (options, callback) => {
588
+ const socket = createConnection({ host: options.host ?? cfg.smtp.host, port: options.port ?? cfg.smtp.port });
589
+ onSocket(socket);
590
+ let settled = false;
591
+ const finish = (error, socketOptions) => {
592
+ if (settled)
593
+ return;
594
+ settled = true;
595
+ clearTimeout(timer);
596
+ socket.removeListener('connect', onConnect);
597
+ socket.removeListener('error', onError);
598
+ signal?.removeEventListener('abort', onAbort);
599
+ if (error !== null)
600
+ socket.destroy();
601
+ callback(error, socketOptions);
602
+ };
603
+ const onConnect = () => {
604
+ if (signal?.aborted === true) {
605
+ finish(signal.reason instanceof Error ? signal.reason : new Error('SMTP connection aborted'));
606
+ }
607
+ else
608
+ finish(null, { connection: socket });
609
+ };
610
+ const onError = (error) => finish(error);
611
+ const onAbort = () => finish(signal?.reason instanceof Error ? signal.reason : new Error('SMTP connection aborted'));
612
+ const timer = setTimeout(() => finish(new Error('SMTP connection timeout')), 30000);
613
+ socket.once('connect', onConnect);
614
+ socket.once('error', onError);
615
+ signal?.addEventListener('abort', onAbort, { once: true });
616
+ if (signal?.aborted === true)
617
+ onAbort();
618
+ },
619
+ });
620
+ this.smtps.set(transporter, undefined);
621
+ return transporter;
601
622
  }
602
623
  /**
603
- * Send through the pooled transporter while making cancellation close it.
624
+ * Give each send its own SMTP connection so cancellation can close that
625
+ * operation without closing another send for the same account.
604
626
  *
605
627
  * An OAuth2 transporter carries a token that was minted when it was built,
606
628
  * so a rejection is retried once against a freshly built one (and a fresh
@@ -610,44 +632,78 @@ export class EmailPool {
610
632
  async sendMail(name, cfg, message, signal) {
611
633
  signal?.throwIfAborted();
612
634
  const attempt = async (forceToken) => {
613
- const token = cfg.authKind === 'oauth2' ? await getFreshAccessToken(name, cfg, { force: forceToken }) : undefined;
614
- const transporter = this.transporter(name, cfg, token);
615
- const onAbort = () => {
616
- this.dropTransporter(name, transporter);
617
- };
618
- signal?.addEventListener('abort', onAbort, { once: true });
635
+ let token;
636
+ if (cfg.authKind === 'oauth2') {
637
+ const refreshing = getFreshAccessToken(name, cfg, { force: forceToken });
638
+ if (signal === undefined)
639
+ token = await refreshing;
640
+ else {
641
+ let onAbort;
642
+ const aborted = new Promise((_resolve, reject) => {
643
+ onAbort = () => reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
644
+ signal.addEventListener('abort', onAbort, { once: true });
645
+ if (signal.aborted)
646
+ onAbort();
647
+ });
648
+ try {
649
+ token = await Promise.race([refreshing, aborted]);
650
+ }
651
+ finally {
652
+ if (onAbort !== undefined)
653
+ signal.removeEventListener('abort', onAbort);
654
+ }
655
+ }
656
+ }
657
+ signal?.throwIfAborted();
658
+ let smtpSocket;
659
+ const transporter = this.transporter(name, cfg, token, signal, socket => {
660
+ smtpSocket = socket;
661
+ this.smtps.set(transporter, socket);
662
+ });
663
+ let onAbort;
664
+ const aborted = signal === undefined ? undefined : new Promise((_resolve, reject) => {
665
+ onAbort = () => {
666
+ smtpSocket?.destroy();
667
+ transporter.close();
668
+ reject(new MailError('SMTP发送已取消;服务器是否已接收邮件无法确认,请先检查邮箱再决定是否重发'));
669
+ };
670
+ signal.addEventListener('abort', onAbort, { once: true });
671
+ });
619
672
  try {
620
- const info = await transporter.sendMail(message);
621
- signal?.throwIfAborted();
673
+ const sending = transporter.sendMail(message);
674
+ const info = aborted === undefined ? await sending : await Promise.race([sending, aborted]);
622
675
  return info;
623
676
  }
624
677
  finally {
625
- signal?.removeEventListener('abort', onAbort);
678
+ if (onAbort !== undefined)
679
+ signal?.removeEventListener('abort', onAbort);
680
+ smtpSocket?.destroy();
681
+ transporter.close();
682
+ this.smtps.delete(transporter);
626
683
  }
627
684
  };
628
685
  try {
629
686
  return await attempt(false);
630
687
  }
631
688
  catch (error) {
632
- signal?.throwIfAborted();
689
+ if (signal?.aborted === true)
690
+ throw error;
633
691
  if (cfg.authKind !== 'oauth2')
634
692
  throw error;
635
- // A pooled connection that already authenticated can fail for reasons no
693
+ // A connection that already authenticated can fail for reasons no
636
694
  // token can fix (a rejected recipient, a full mailbox). Only a credential
637
695
  // rejection is worth a second, freshly-tokened attempt — and a token
638
696
  // store that refused outright is reported as itself.
639
697
  if (!looksLikeAuthFailure(error))
640
698
  throw this.oauth2ErrorOf(error);
641
- // The cached transporter holds the old token: it has to go, or the retry
642
- // would reuse the very credential that was just refused.
643
- const stale = this.smtps.get(name);
644
- if (stale !== undefined)
645
- this.dropTransporter(name, stale);
699
+ // A fresh transporter is created for the retry so it cannot reuse the
700
+ // credential that was just refused.
646
701
  try {
647
702
  return await attempt(true);
648
703
  }
649
704
  catch (retryError) {
650
- signal?.throwIfAborted();
705
+ if (signal !== undefined && Boolean(signal.aborted))
706
+ throw retryError;
651
707
  throw this.oauth2ErrorOf(retryError);
652
708
  }
653
709
  }
package/lib/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Live settings, account-pool ownership, and independent tool/web watch cursors. */
2
- import { clampInt, presetNamesIn, resolveEmailSettings, serializeAccountsYaml } from './config.js';
2
+ import { clampInt, mergeAccountDefaults, 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
5
  import { liveConfig } from './host-config.js';
@@ -43,7 +43,7 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
43
43
  const shared = Object.fromEntries(['provider', 'user', 'password', 'senderName', 'authUser', 'authPassword', 'clientId', 'authKind', 'imap', 'smtp', 'inboxFolder']
44
44
  .filter(key => config[key] !== undefined).map(key => [key, config[key]]));
45
45
  const cards = config.accounts && Object.keys(config.accounts).length > 0
46
- ? Object.fromEntries(Object.entries(config.accounts).map(([key, account]) => [key, { ...shared, ...account }]))
46
+ ? Object.fromEntries(Object.entries(config.accounts).map(([key, account]) => [key, mergeAccountDefaults(shared, account)]))
47
47
  : typeof config.user === 'string' && config.user !== '' ? { default: shared } : {};
48
48
  return EmailSettingsSchema({ ...toSettingsBase(config), ...endpointDefaults,
49
49
  accountsYaml: config.accountsYaml ?? serializeAccountsYaml(cards, config.defaultAccount),
package/lib/web.js CHANGED
@@ -823,7 +823,12 @@ export class EmailSettingsBackend {
823
823
  // resurrect a hidden old account on the next read.
824
824
  const section = Object.hasOwn(value, 'accountsYaml')
825
825
  ? { ...value, accounts: {}, user: '', password: '', authUser: '', authPassword: '' }
826
- : value;
826
+ : { ...value };
827
+ if (Object.hasOwn(value, 'accountsYaml')) {
828
+ // The form displays the default card's resolved endpoints; those are not shared edits.
829
+ delete section.imap;
830
+ delete section.smtp;
831
+ }
827
832
  await this.ctx.settings.update(this.namespace, section, expectedRevision);
828
833
  }
829
834
  else
@@ -868,6 +873,7 @@ export class EmailSettingsBackend {
868
873
  throw new Error(`未知账号 "${name}",可用:${available.join('、')}`);
869
874
  }
870
875
  const target = { account: name, imapHost: cfg.imap.host, imapPort: cfg.imap.port };
876
+ const targetDescription = '账号 "' + name + '" · IMAP ' + cfg.imap.host + ':' + cfg.imap.port;
871
877
  // An OAuth2 account has no password to check: without a token there is
872
878
  // nothing to dial with, and a failed dial would only say so less clearly.
873
879
  if (cfg.authKind === 'oauth2') {
@@ -875,7 +881,7 @@ export class EmailSettingsBackend {
875
881
  await getFreshAccessToken(name, cfg);
876
882
  }
877
883
  catch (error) {
878
- throw new Error(messageOf(error, '尚未登录:请先在设置页完成设备码登录'));
884
+ throw new Error(targetDescription + '(OAuth2):' + redactCredentials(messageOf(error, '尚未登录:请先在设置页完成设备码登录')));
879
885
  }
880
886
  }
881
887
  const pool = new EmailPool(settings);
@@ -889,14 +895,18 @@ export class EmailSettingsBackend {
889
895
  // actionable hint instead of the opaque message. The server's own text is
890
896
  // redacted first: a refused authentication string is echoed verbatim by
891
897
  // many servers, and for XOAUTH2 that blob carries the access token.
892
- const raw = redactCredentials(messageOf(error, 'unknown error'));
898
+ let raw = redactCredentials(messageOf(error, 'unknown error'));
899
+ for (const secret of [cfg.password, cfg.authPassword]) {
900
+ if (secret && secret.length >= 4)
901
+ raw = raw.split(secret).join('[redacted]');
902
+ }
893
903
  const lower = raw.toLowerCase();
894
904
  if (lower.includes('command failed') || lower.includes('authentication') || lower.includes('login')) {
895
905
  throw new Error(cfg.authKind === 'oauth2'
896
- ? '邮箱登录失败:请在设置页重新完成设备码登录(' + raw + ')'
897
- : '邮箱登录失败:请检查邮箱地址与授权码(' + raw + ')');
906
+ ? targetDescription + '(OAuth2):邮箱登录失败:请在设置页重新完成设备码登录(' + raw + ')'
907
+ : targetDescription + ':邮箱登录失败:请检查邮箱地址与授权码(' + raw + ')');
898
908
  }
899
- throw error;
909
+ throw new Error(targetDescription + ':' + raw);
900
910
  }
901
911
  finally {
902
912
  pool.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "description": "DSH 邮件插件:IMAP/SMTP 收发搜索、回复转发、附件与整理,多账号卡片设置页、Outlook OAuth2 登录与发信审批。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",