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 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
- * 账号在 YAML 里只存 provider id:卡片带着的 imap/smtp 是「这个预设展开出来的
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
- * 卡片 meta 行里的「连去哪」:账号只存 provider id,端点是预设展开的,所以这里
942
- * 一律回头查预设表,不读草稿自己的 imap/smtp(那份副本已经不存在了)。
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
- if (provider === "") return t("card.noProvider");
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
- if (builtin === undefined) return t(entry[1]);
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
- if (preset === undefined) return presetLabelOf(provider, undefined);
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)
@@ -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 === false ? [] : 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 === false ? [] : found).slice().sort((a, b) => b - a);
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 === false ? [] : result))].sort((a, b) => b - a);
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.filename ?? att.filename ?? attachments[index].filename);
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
- * The endpoints are read from the *preset* only: an account stores a provider
60
- * id, so a stale hand-written `imap.host` in an existing YAML must not show up
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.6",
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": "^1.7.0",
65
- "mailparser": "^3.9.22",
66
- "nodemailer": "^9.0.5",
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
  }