dsh-email 0.14.6 → 0.14.7
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 +7 -0
- package/README.en.md +7 -0
- package/README.md +7 -0
- package/lib/client.js +22 -14
- package/lib/mail-client.js +15 -11
- package/lib/web.js +2 -3
- package/package.json +4 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.7(2026-10-02)
|
|
4
|
+
|
|
5
|
+
- Fix account cards displaying preset endpoints instead of explicitly configured servers; refresh the summary after saves and provider changes without persisting projections.
|
|
6
|
+
- Upgrade ImapFlow, Mailparser, Nodemailer and the transitive IP address parser; use Nodemailer's bundled types.
|
|
7
|
+
- Handle missing IMAP messages, empty searches and missing attachment streams with explicit recovery errors.
|
|
8
|
+
- Add regression coverage for endpoint display and empty IMAP responses. Windows: 304 tests; production dependency audit: 0 findings.
|
|
9
|
+
|
|
3
10
|
## 0.14.6(2026-10-02)
|
|
4
11
|
|
|
5
12
|
- 完成 [PR #18](https://github.com/STARDUSTLC666/dsh-email/pull/18) 的兼容合并:未修改的表单端点不再产出空对象,避免旧设置和不完整草稿覆盖配置文件里手动填写的 IMAP/SMTP 服务器。
|
package/README.en.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# dsh-email
|
|
2
2
|
|
|
3
|
+
## 0.14.7 update (2026-10-02)
|
|
4
|
+
|
|
5
|
+
- Account cards display the effective IMAP/SMTP endpoints. Changing providers clears stale display values without persisting the endpoint summary into account configuration.
|
|
6
|
+
- Upgrade to ImapFlow 2.2.1, Mailparser 3.9.33 and Nodemailer 10.0.13, adapt upstream types and empty responses, and eliminate the production dependency audit findings.
|
|
7
|
+
- Give recovery guidance when a message or attachment disappears during a request; empty search responses remain empty lists.
|
|
8
|
+
- All 304 tests pass on Windows, including local SMTP TLS, STARTTLS, cancellation and OAuth2 protocol fixtures. Production mailbox login and delivery still require user acceptance.
|
|
9
|
+
|
|
3
10
|
## 0.14.6 update (2026-10-02)
|
|
4
11
|
|
|
5
12
|
Completes [PR #18](https://github.com/STARDUSTLC666/dsh-email/pull/18): untouched saved form endpoints and partial drafts with empty endpoints preserve explicitly configured row IMAP/SMTP servers. The Outlook 587/STARTTLS fix and deliberate port/TLS overrides remain supported.
|
package/README.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
# dsh-email
|
|
4
4
|
|
|
5
|
+
## 0.14.7 更新(2026-10-02)
|
|
6
|
+
|
|
7
|
+
- 账号卡片显示实际生效的 IMAP/SMTP 地址;切换服务商时清掉旧地址的显示,服务器摘要不会写回账号配置。
|
|
8
|
+
- 升级 ImapFlow 2.2.1、Mailparser 3.9.33、Nodemailer 10.0.13,适配上游类型与空响应,生产依赖审计为 0。
|
|
9
|
+
- 邮件或附件在请求期间消失时给出恢复提示;空搜索结果保持为空列表。
|
|
10
|
+
- Windows 304 项测试通过,包括本地 SMTP TLS、STARTTLS、取消发送与 OAuth2 协议样例。真实邮箱登录与发信仍需使用者验收。
|
|
11
|
+
|
|
5
12
|
## 0.14.6 更新(2026-10-02)
|
|
6
13
|
|
|
7
14
|
完成 [PR #18](https://github.com/STARDUSTLC666/dsh-email/pull/18):旧设置里未修改的端点默认值,以及只包含空端点的不完整草稿,保留配置文件中手动填写的 IMAP/SMTP 服务器。沿用 Outlook 587/STARTTLS 修复,并保留用户明确指定的端口和 TLS 组合。
|
package/lib/client.js
CHANGED
|
@@ -751,9 +751,8 @@ function isOauthDraft(draft) {
|
|
|
751
751
|
|
|
752
752
|
/**
|
|
753
753
|
* 服务端卡片(永不含密码明文)-> 草稿。user 为空表示「就留空」,不是未加载。
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
* 时把一份会过期的端点抄回 YAML。卡片上的端点显示改从 presets 查(providerMetaText)。
|
|
754
|
+
* 卡片的 imap/smtp 是后端计算的生效端点,只用于显示,不写回 YAML。
|
|
755
|
+
* 它们可能包含用户明确指定的服务器,不能用服务商默认值代替。
|
|
757
756
|
* authKind/oauthState/oauthUser 是卡片的登录态投影,只用于显示与本地判定,
|
|
758
757
|
* 不进 draftToInput(它们不是 YAML 的字段,后端认的是账号自己的 oauth 段)。
|
|
759
758
|
*/
|
|
@@ -848,6 +847,8 @@ function applyPreset(draft, provider) {
|
|
|
848
847
|
const next = provider === undefined || provider === null ? "" : String(provider);
|
|
849
848
|
return Object.assign({}, draft, {
|
|
850
849
|
provider: next,
|
|
850
|
+
imap: undefined,
|
|
851
|
+
smtp: undefined,
|
|
851
852
|
password: "",
|
|
852
853
|
authKind: undefined,
|
|
853
854
|
oauthState: "none",
|
|
@@ -938,25 +939,32 @@ function presetEndpointText(endpoint) {
|
|
|
938
939
|
}
|
|
939
940
|
|
|
940
941
|
/**
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* 内置预设显示内置名 + 它的端点;自定义预设显示 label(没 label 用预设名)+ 端点;
|
|
944
|
-
* 名字连预设都没定义时明说「未定义预设」;压根没选服务商就直说。
|
|
942
|
+
* 卡片显示后端的生效服务器,含手动覆盖;换服务商的未保存草稿才查预设。
|
|
943
|
+
* 保存后的新快照优先,避免自定义预设更新后继续显示旧地址。
|
|
945
944
|
*/
|
|
946
|
-
function providerMetaText(draft, presets) {
|
|
945
|
+
function providerMetaText(draft, presets, cards) {
|
|
947
946
|
const provider = String(draft.provider || "").trim();
|
|
948
|
-
|
|
947
|
+
const saved = (cards || []).find(card => card.name === draft.name)
|
|
948
|
+
|| (cards || []).find(card => card.name === draft.originalName);
|
|
949
|
+
const actual = saved && String(saved.provider || "").trim() === provider ? saved : draft;
|
|
950
|
+
const endpoints = fallback => ({
|
|
951
|
+
imap: actual.imap && actual.imap.host ? actual.imap : fallback && fallback.imap,
|
|
952
|
+
smtp: actual.smtp && actual.smtp.host ? actual.smtp : fallback && fallback.smtp,
|
|
953
|
+
});
|
|
954
|
+
const describe = (name, fallback) => {
|
|
955
|
+
const target = endpoints(fallback);
|
|
956
|
+
return name + t("card.builtinEndpoints", { imap: presetEndpointText(target.imap), smtp: presetEndpointText(target.smtp) });
|
|
957
|
+
};
|
|
958
|
+
if (provider === "") return actual.imap?.host || actual.smtp?.host ? describe(t("provider.custom")) : t("card.noProvider");
|
|
949
959
|
const entry = PROVIDERS.filter(([value]) => value === provider)[0];
|
|
950
960
|
if (entry) {
|
|
951
961
|
const builtin = (presets && presets.builtin && presets.builtin[provider]) || undefined;
|
|
952
|
-
|
|
953
|
-
return t(entry[1]) + t("card.builtinEndpoints", { imap: presetEndpointText(builtin.imap), smtp: presetEndpointText(builtin.smtp) });
|
|
962
|
+
return describe(t(entry[1]), builtin);
|
|
954
963
|
}
|
|
955
964
|
const custom = (presets && presets.custom) || {};
|
|
956
965
|
if (!Object.prototype.hasOwnProperty.call(custom, provider)) return provider + t("provider.undefinedPresetSuffix");
|
|
957
966
|
const preset = custom[provider] || undefined;
|
|
958
|
-
|
|
959
|
-
return presetLabelOf(provider, preset) + t("card.customEndpoint", { endpoint: presetEndpointText(preset.imap) });
|
|
967
|
+
return describe(presetLabelOf(provider, preset), preset);
|
|
960
968
|
}
|
|
961
969
|
|
|
962
970
|
// ---------------------------------------------------------------------------
|
|
@@ -1717,7 +1725,7 @@ function AccountCardsEditor(props) {
|
|
|
1717
1725
|
t("card.meta", {
|
|
1718
1726
|
address: draft.user || t("card.noAddress"),
|
|
1719
1727
|
folder: draft.inboxFolder || "INBOX",
|
|
1720
|
-
provider: providerMetaText(draft, presets),
|
|
1728
|
+
provider: providerMetaText(draft, presets, cards),
|
|
1721
1729
|
})),
|
|
1722
1730
|
status
|
|
1723
1731
|
? h("div", { className: "dshe-hint" + (status.kind === "error" ? " dshe-acc-warn" : "") }, status.text)
|
package/lib/mail-client.js
CHANGED
|
@@ -356,7 +356,7 @@ export class EmailPool {
|
|
|
356
356
|
// 附件索引只需要 MIME 结构:bodyStructure 已经给出每个附件的 part/名称/大小,
|
|
357
357
|
// 不必为了拿它先拉整封 source(第一次就下载附件的邮件也一样)。
|
|
358
358
|
const message = await client.fetchOne(uid, { uid: true, bodyStructure: true }, { uid: true });
|
|
359
|
-
if (message === false) {
|
|
359
|
+
if (message === false || message === undefined) {
|
|
360
360
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
361
361
|
}
|
|
362
362
|
signal?.throwIfAborted();
|
|
@@ -371,7 +371,7 @@ export class EmailPool {
|
|
|
371
371
|
const full = message.source !== undefined
|
|
372
372
|
? message
|
|
373
373
|
: await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
374
|
-
if (full === false || full.source === undefined) {
|
|
374
|
+
if (full === false || full === undefined || full.source === undefined) {
|
|
375
375
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
376
376
|
}
|
|
377
377
|
const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
|
|
@@ -585,7 +585,7 @@ export class EmailPool {
|
|
|
585
585
|
greetingTimeout: 10000,
|
|
586
586
|
socketTimeout: 60000,
|
|
587
587
|
getSocket: (options, callback) => {
|
|
588
|
-
const socket = createConnection({ host: options.host ?? cfg.smtp.host, port: options.port ?? cfg.smtp.port });
|
|
588
|
+
const socket = createConnection({ host: options.host ?? cfg.smtp.host, port: Number(options.port ?? cfg.smtp.port) });
|
|
589
589
|
onSocket(socket);
|
|
590
590
|
let settled = false;
|
|
591
591
|
const finish = (error, socketOptions) => {
|
|
@@ -715,6 +715,8 @@ export class EmailPool {
|
|
|
715
715
|
async downloadPartText(client, uid, part, maxBytes, signal) {
|
|
716
716
|
const dl = await client.download(uid, part.key, { uid: true, maxBytes });
|
|
717
717
|
signal?.throwIfAborted();
|
|
718
|
+
if (dl.content === undefined)
|
|
719
|
+
throw new MailError('邮件正文分段已不存在;将尝试重新读取邮件');
|
|
718
720
|
const buf = await collectStream(dl.content, maxBytes, signal);
|
|
719
721
|
signal?.throwIfAborted();
|
|
720
722
|
return buf.toString('utf8');
|
|
@@ -757,7 +759,7 @@ export class EmailPool {
|
|
|
757
759
|
query.before = until;
|
|
758
760
|
const found = await client.search(query, { uid: true });
|
|
759
761
|
signal?.throwIfAborted();
|
|
760
|
-
uids = found
|
|
762
|
+
uids = Array.isArray(found) ? found : [];
|
|
761
763
|
scopeCount = uids.length;
|
|
762
764
|
}
|
|
763
765
|
else if (total > 0) {
|
|
@@ -785,7 +787,7 @@ export class EmailPool {
|
|
|
785
787
|
const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
786
788
|
const found = await client.search({ seen: false }, { uid: true });
|
|
787
789
|
signal?.throwIfAborted();
|
|
788
|
-
const uids = (found
|
|
790
|
+
const uids = (Array.isArray(found) ? found : []).slice().sort((a, b) => b - a);
|
|
789
791
|
return { account: name, folder: folderName, uidValidity, count: uids.length, uids };
|
|
790
792
|
}, true, signal);
|
|
791
793
|
}
|
|
@@ -822,7 +824,7 @@ export class EmailPool {
|
|
|
822
824
|
client.search({ cc: query, ...dateRange }, { uid: true }),
|
|
823
825
|
]);
|
|
824
826
|
signal?.throwIfAborted();
|
|
825
|
-
const uids = [...new Set(found.flatMap(result => result
|
|
827
|
+
const uids = [...new Set(found.flatMap(result => Array.isArray(result) ? result : []))].sort((a, b) => b - a);
|
|
826
828
|
if (uids.length > 0) {
|
|
827
829
|
// The sample has to cover the requested page (offset + limit) — the same
|
|
828
830
|
// window the fallback scan looks at — so one FETCH serves both the
|
|
@@ -938,7 +940,7 @@ export class EmailPool {
|
|
|
938
940
|
// 先只要信封与 MIME 结构,正文按 text/* 分段下载:带 20 MiB 附件的邮件
|
|
939
941
|
// 只为看正文时不再整封拉下来,附件元数据直接复用 bodyStructure。
|
|
940
942
|
const message = await client.fetchOne(uid, { uid: true, envelope: true, bodyStructure: true }, { uid: true });
|
|
941
|
-
if (message === false) {
|
|
943
|
+
if (message === false || message === undefined) {
|
|
942
944
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
943
945
|
}
|
|
944
946
|
signal?.throwIfAborted();
|
|
@@ -979,7 +981,7 @@ export class EmailPool {
|
|
|
979
981
|
const full = message.source !== undefined
|
|
980
982
|
? message
|
|
981
983
|
: await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
982
|
-
if (full === false || full.source === undefined) {
|
|
984
|
+
if (full === false || full === undefined || full.source === undefined) {
|
|
983
985
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
984
986
|
}
|
|
985
987
|
const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
|
|
@@ -995,7 +997,7 @@ export class EmailPool {
|
|
|
995
997
|
return this.withImap(name, folderName, async (client) => {
|
|
996
998
|
const before = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
|
|
997
999
|
signal?.throwIfAborted();
|
|
998
|
-
if (before === false) {
|
|
1000
|
+
if (before === false || before === undefined) {
|
|
999
1001
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
1000
1002
|
}
|
|
1001
1003
|
let seen = before.flags?.has('\\Seen') === true;
|
|
@@ -1090,8 +1092,10 @@ export class EmailPool {
|
|
|
1090
1092
|
}
|
|
1091
1093
|
const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
|
|
1092
1094
|
signal?.throwIfAborted();
|
|
1095
|
+
if (dl.content === undefined)
|
|
1096
|
+
throw new MailError('附件分段已不存在,请重新读取邮件后再下载');
|
|
1093
1097
|
const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
|
|
1094
|
-
const safeName = sanitizeFilename(dl.meta
|
|
1098
|
+
const safeName = sanitizeFilename(dl.meta?.filename ?? att.filename ?? attachments[index].filename);
|
|
1095
1099
|
// Default the destination to the session workspace so the model can
|
|
1096
1100
|
// read the file back; an explicit downloadDir always wins.
|
|
1097
1101
|
const dir = this.settings.downloadDirExplicit
|
|
@@ -1135,7 +1139,7 @@ export class EmailPool {
|
|
|
1135
1139
|
// leaves a half-written mailbox state behind.
|
|
1136
1140
|
const built = await this.withImap(name, folderName, async (client) => {
|
|
1137
1141
|
const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
|
|
1138
|
-
if (message === false || message.source === undefined) {
|
|
1142
|
+
if (message === false || message === undefined || message.source === undefined) {
|
|
1139
1143
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
1140
1144
|
}
|
|
1141
1145
|
const ids = extractMessageIds(message.source);
|
package/lib/web.js
CHANGED
|
@@ -56,9 +56,8 @@ function accountEndpoint(preset, own, fallback) {
|
|
|
56
56
|
* render as a card. Nothing here resolves, validates or touches the network —
|
|
57
57
|
* an unknown provider degrades that one card's endpoints, never the list.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* in the editor as if it still drove the connection.
|
|
59
|
+
* Explicit account endpoints override the preset, exactly as in connection
|
|
60
|
+
* resolution. The card reports them for display without writing them back.
|
|
62
61
|
*
|
|
63
62
|
* `tokens` is the OAuth2 login state, looked up once per snapshot rather than
|
|
64
63
|
* per card: the token file is read from disk, and a card is rendered on every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.7",
|
|
4
4
|
"description": "DSH 邮件插件:IMAP/SMTP 收发搜索、回复转发、附件与整理,多账号卡片设置页、Outlook OAuth2 登录与发信审批。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -61,16 +61,15 @@
|
|
|
61
61
|
}
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"imapflow": "^
|
|
65
|
-
"mailparser": "^3.9.
|
|
66
|
-
"nodemailer": "^
|
|
64
|
+
"imapflow": "^2.2.1",
|
|
65
|
+
"mailparser": "^3.9.33",
|
|
66
|
+
"nodemailer": "^10.0.13",
|
|
67
67
|
"schemastery": "^3.18.0",
|
|
68
68
|
"yaml": "^2.9.0",
|
|
69
69
|
"@deepseek-ai/schemastery": "^3.18.3"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@types/node": "^26.2.0",
|
|
73
|
-
"@types/nodemailer": "^8.0.1",
|
|
74
73
|
"typescript": "^5.7.3"
|
|
75
74
|
}
|
|
76
75
|
}
|