dsh-email 0.14.3 → 0.14.4
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 +5 -0
- package/README.en.md +0 -4
- package/README.md +0 -6
- package/lib/mail-client.d.ts +5 -7
- package/lib/mail-client.js +104 -47
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.4(2026-09-29)
|
|
4
|
+
|
|
5
|
+
- 修复:SMTP 发送改为**每次发送独占连接**,取消(AbortSignal)时只销毁本次连接的 socket 并立即拒绝,不再影响同账号的其它发送,也不会在取消后仍把邮件发出去。
|
|
6
|
+
- 修复:OAuth2 令牌刷新与取消的竞态——取消后即使令牌刷新成功也不再继续发送。
|
|
7
|
+
- 测试:修正 TLS fixture 的两处握手缺陷(STARTTLS 明文阶段未发送 220;取消用例把 greeting 压住导致走不到 TLS 升级),TLS 与取消回归现可稳定通过(285 项)。
|
|
3
8
|
## 0.14.3(2026-09-28)
|
|
4
9
|
|
|
5
10
|
- 兼容验证更新到 Harness 0.2.0-rc.1:282 项邮件测试与 18 插件共同加载检查通过。
|
package/README.en.md
CHANGED
|
@@ -55,10 +55,6 @@ Example:
|
|
|
55
55
|
|
|
56
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
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.
|
|
61
|
-
|
|
62
58
|
**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
59
|
|
|
64
60
|
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.
|
package/README.md
CHANGED
|
@@ -63,14 +63,8 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
|
|
|
63
63
|
|
|
64
64
|
当前验证基线为官方源码构建的 Harness **0.2.0-rc.1**(2026-09-28,含本地工具调度器 `Symbol.for` 修复)。18 个插件共同加载;邮件设置覆盖旧配置迁移、真实网页编辑与自动保存、刷新恢复、修订冲突和重启持久化检查。本轮使用隔离测试账号,未连接真实邮箱或发送邮件。
|
|
65
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、技能注册及离线只读调用检查通过。
|
|
69
|
-
|
|
70
66
|
**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
67
|
|
|
72
|
-
2026-09-10,npm `dsh-email@0.10.6` 曾通过真实 QQ 邮箱目录、列表、读取和搜索,以及设置页“测试连接”“保存并应用”检查;授权码留空时能继续使用 `DSH_EMAIL_PASSWORD`。独立 SMTP 登录认证也已通过。此次复验未连接真实邮箱,未发送、修改或删除邮件。
|
|
73
|
-
|
|
74
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 仍在快速迭代,上述版本是实测基线。
|
|
75
69
|
|
|
76
70
|
## 安装
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -108,8 +108,7 @@ export declare function extractMessageIds(source: Buffer): {
|
|
|
108
108
|
*/
|
|
109
109
|
export declare function buildReplyMessage(original: OriginalDigest, mode: EmailReplyMode, selfAddress: string | readonly string[], text: string, forwardTo?: string): BuiltReply;
|
|
110
110
|
/**
|
|
111
|
-
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
112
|
-
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
111
|
+
* One mailbox pool for the whole plugin: pooled IMAP connections per account.
|
|
113
112
|
*/
|
|
114
113
|
export declare class EmailPool {
|
|
115
114
|
private readonly settings;
|
|
@@ -155,14 +154,13 @@ export declare class EmailPool {
|
|
|
155
154
|
startIdleSweep(): void;
|
|
156
155
|
dispose(): void;
|
|
157
156
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* re-minted in sendMail, which rebuilds the transporter.
|
|
157
|
+
* Create an unpooled sender with its own socket. The socket callback exposes
|
|
158
|
+
* only the public SMTP transport hook to the cancellation owner.
|
|
161
159
|
*/
|
|
162
160
|
private transporter;
|
|
163
|
-
private dropTransporter;
|
|
164
161
|
/**
|
|
165
|
-
*
|
|
162
|
+
* Give each send its own SMTP connection so cancellation can close that
|
|
163
|
+
* operation without closing another send for the same account.
|
|
166
164
|
*
|
|
167
165
|
* An OAuth2 transporter carries a token that was minted when it was built,
|
|
168
166
|
* so a rejection is retried once against a freshly built one (and a fresh
|
package/lib/mail-client.js
CHANGED
|
@@ -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';
|
|
@@ -293,8 +294,7 @@ const READ_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
293
294
|
/** Folder names change rarely; a short TTL keeps email_folders off the wire. */
|
|
294
295
|
const FOLDER_CACHE_TTL_MS = 60 * 1000;
|
|
295
296
|
/**
|
|
296
|
-
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
297
|
-
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
297
|
+
* One mailbox pool for the whole plugin: pooled IMAP connections per account.
|
|
298
298
|
*/
|
|
299
299
|
export class EmailPool {
|
|
300
300
|
settings;
|
|
@@ -566,41 +566,64 @@ export class EmailPool {
|
|
|
566
566
|
this.idleTimer = undefined;
|
|
567
567
|
for (const name of [...this.imaps.keys()])
|
|
568
568
|
void this.evictImap(name);
|
|
569
|
-
for (const transporter of this.smtps
|
|
569
|
+
for (const [transporter, socket] of this.smtps) {
|
|
570
|
+
socket?.destroy();
|
|
570
571
|
transporter.close();
|
|
572
|
+
}
|
|
571
573
|
this.smtps.clear();
|
|
572
574
|
}
|
|
573
575
|
/**
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
* re-minted in sendMail, which rebuilds the transporter.
|
|
576
|
+
* Create an unpooled sender with its own socket. The socket callback exposes
|
|
577
|
+
* only the public SMTP transport hook to the cancellation owner.
|
|
577
578
|
*/
|
|
578
|
-
transporter(name, cfg, accessToken) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
579
|
+
transporter(name, cfg, accessToken, signal, onSocket) {
|
|
580
|
+
const transporter = nodemailer.createTransport({
|
|
581
|
+
host: cfg.smtp.host,
|
|
582
|
+
port: cfg.smtp.port,
|
|
583
|
+
secure: cfg.smtp.secure,
|
|
584
|
+
auth: smtpAuthOf(cfg, accessToken),
|
|
585
|
+
connectionTimeout: 30000,
|
|
586
|
+
greetingTimeout: 10000,
|
|
587
|
+
socketTimeout: 60000,
|
|
588
|
+
getSocket: (options, callback) => {
|
|
589
|
+
const socket = createConnection({ host: options.host ?? cfg.smtp.host, port: options.port ?? cfg.smtp.port });
|
|
590
|
+
onSocket(socket);
|
|
591
|
+
let settled = false;
|
|
592
|
+
const finish = (error, socketOptions) => {
|
|
593
|
+
if (settled)
|
|
594
|
+
return;
|
|
595
|
+
settled = true;
|
|
596
|
+
clearTimeout(timer);
|
|
597
|
+
socket.removeListener('connect', onConnect);
|
|
598
|
+
socket.removeListener('error', onError);
|
|
599
|
+
signal?.removeEventListener('abort', onAbort);
|
|
600
|
+
if (error !== null)
|
|
601
|
+
socket.destroy();
|
|
602
|
+
callback(error, socketOptions);
|
|
603
|
+
};
|
|
604
|
+
const onConnect = () => {
|
|
605
|
+
if (signal?.aborted === true) {
|
|
606
|
+
finish(signal.reason instanceof Error ? signal.reason : new Error('SMTP connection aborted'));
|
|
607
|
+
}
|
|
608
|
+
else
|
|
609
|
+
finish(null, { connection: socket });
|
|
610
|
+
};
|
|
611
|
+
const onError = (error) => finish(error);
|
|
612
|
+
const onAbort = () => finish(signal?.reason instanceof Error ? signal.reason : new Error('SMTP connection aborted'));
|
|
613
|
+
const timer = setTimeout(() => finish(new Error('SMTP connection timeout')), 30000);
|
|
614
|
+
socket.once('connect', onConnect);
|
|
615
|
+
socket.once('error', onError);
|
|
616
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
617
|
+
if (signal?.aborted === true)
|
|
618
|
+
onAbort();
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
this.smtps.set(transporter, undefined);
|
|
622
|
+
return transporter;
|
|
601
623
|
}
|
|
602
624
|
/**
|
|
603
|
-
*
|
|
625
|
+
* Give each send its own SMTP connection so cancellation can close that
|
|
626
|
+
* operation without closing another send for the same account.
|
|
604
627
|
*
|
|
605
628
|
* An OAuth2 transporter carries a token that was minted when it was built,
|
|
606
629
|
* so a rejection is retried once against a freshly built one (and a fresh
|
|
@@ -610,44 +633,78 @@ export class EmailPool {
|
|
|
610
633
|
async sendMail(name, cfg, message, signal) {
|
|
611
634
|
signal?.throwIfAborted();
|
|
612
635
|
const attempt = async (forceToken) => {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
636
|
+
let token;
|
|
637
|
+
if (cfg.authKind === 'oauth2') {
|
|
638
|
+
const refreshing = getFreshAccessToken(name, cfg, { force: forceToken });
|
|
639
|
+
if (signal === undefined)
|
|
640
|
+
token = await refreshing;
|
|
641
|
+
else {
|
|
642
|
+
let onAbort;
|
|
643
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
644
|
+
onAbort = () => reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
|
|
645
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
646
|
+
if (signal.aborted)
|
|
647
|
+
onAbort();
|
|
648
|
+
});
|
|
649
|
+
try {
|
|
650
|
+
token = await Promise.race([refreshing, aborted]);
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
if (onAbort !== undefined)
|
|
654
|
+
signal.removeEventListener('abort', onAbort);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
signal?.throwIfAborted();
|
|
659
|
+
let smtpSocket;
|
|
660
|
+
const transporter = this.transporter(name, cfg, token, signal, socket => {
|
|
661
|
+
smtpSocket = socket;
|
|
662
|
+
this.smtps.set(transporter, socket);
|
|
663
|
+
});
|
|
664
|
+
let onAbort;
|
|
665
|
+
const aborted = signal === undefined ? undefined : new Promise((_resolve, reject) => {
|
|
666
|
+
onAbort = () => {
|
|
667
|
+
smtpSocket?.destroy();
|
|
668
|
+
transporter.close();
|
|
669
|
+
reject(new MailError('SMTP发送已取消;服务器是否已接收邮件无法确认,请先检查邮箱再决定是否重发'));
|
|
670
|
+
};
|
|
671
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
672
|
+
});
|
|
619
673
|
try {
|
|
620
|
-
const
|
|
621
|
-
|
|
674
|
+
const sending = transporter.sendMail(message);
|
|
675
|
+
const info = aborted === undefined ? await sending : await Promise.race([sending, aborted]);
|
|
622
676
|
return info;
|
|
623
677
|
}
|
|
624
678
|
finally {
|
|
625
|
-
|
|
679
|
+
if (onAbort !== undefined)
|
|
680
|
+
signal?.removeEventListener('abort', onAbort);
|
|
681
|
+
smtpSocket?.destroy();
|
|
682
|
+
transporter.close();
|
|
683
|
+
this.smtps.delete(transporter);
|
|
626
684
|
}
|
|
627
685
|
};
|
|
628
686
|
try {
|
|
629
687
|
return await attempt(false);
|
|
630
688
|
}
|
|
631
689
|
catch (error) {
|
|
632
|
-
signal?.
|
|
690
|
+
if (signal?.aborted === true)
|
|
691
|
+
throw error;
|
|
633
692
|
if (cfg.authKind !== 'oauth2')
|
|
634
693
|
throw error;
|
|
635
|
-
// A
|
|
694
|
+
// A connection that already authenticated can fail for reasons no
|
|
636
695
|
// token can fix (a rejected recipient, a full mailbox). Only a credential
|
|
637
696
|
// rejection is worth a second, freshly-tokened attempt — and a token
|
|
638
697
|
// store that refused outright is reported as itself.
|
|
639
698
|
if (!looksLikeAuthFailure(error))
|
|
640
699
|
throw this.oauth2ErrorOf(error);
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
const stale = this.smtps.get(name);
|
|
644
|
-
if (stale !== undefined)
|
|
645
|
-
this.dropTransporter(name, stale);
|
|
700
|
+
// A fresh transporter is created for the retry so it cannot reuse the
|
|
701
|
+
// credential that was just refused.
|
|
646
702
|
try {
|
|
647
703
|
return await attempt(true);
|
|
648
704
|
}
|
|
649
705
|
catch (retryError) {
|
|
650
|
-
signal
|
|
706
|
+
if (signal !== undefined && Boolean(signal.aborted))
|
|
707
|
+
throw retryError;
|
|
651
708
|
throw this.oauth2ErrorOf(retryError);
|
|
652
709
|
}
|
|
653
710
|
}
|