dsh-email 0.15.1 → 0.15.3

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
@@ -4,6 +4,17 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.15.3 (2026-10-04)
8
+
9
+ - 完成 [#21](https://github.com/STARDUSTLC666/dsh-email/issues/21):邮箱账号页新增「显示新邮件弹窗」开关,自动保存、重启后保留。关闭时隐藏当前卡片并停止自动邮件检查,不影响手动邮件工具。
10
+ - 重新开启建立新基线,不补弹关闭期间的旧邮件;跨页面保存的开关在下一次本地设置刷新时生效。关闭或卸载后,迟到的网络响应不能重新显示弹窗。
11
+ - 验收范围见 [0.15.3 通知开关检查](docs/validation/0.15.3.md)。
12
+
13
+ ## 0.15.2 (2026-10-03)
14
+
15
+ - 修复语言切换后设置菜单滞后一轮;英文草稿、账号、发送失败引导不再回退中文,保留结果不明时禁止盲目重发的提示。
16
+ - 验收范围见 [中英文界面检查](docs/validation-language-2026-10-03.md)。
17
+
7
18
  ## 0.15.1 (2026-10-03)
8
19
 
9
20
  - 修复 email_draft 使用宿主不支持的 JSON Schema maxItems,导致邮件工具注册失败的问题。附件最多 10 个的限制仍在执行前校验,并在工具说明中显示。
package/README.en.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [中文](README.md)
4
4
 
5
+ ![dsh-email whale girl plugin cover](https://raw.githubusercontent.com/STARDUSTLC666/dsh-email/main/assets/cover-whale-girl.png)
6
+
5
7
  Read, send, search and organize email from DSH with multiple accounts.
6
8
 
7
9
  [![npm](https://img.shields.io/npm/v/dsh-email)](https://www.npmjs.com/package/dsh-email) [![downloads](https://img.shields.io/npm/dm/dsh-email)](https://www.npmjs.com/package/dsh-email)
@@ -11,6 +13,7 @@ Read, send, search and organize email from DSH with multiple accounts.
11
13
  - Read, send, reply and forward using IMAP and SMTP.
12
14
  - Manage attachments, search results and mailbox organization.
13
15
  - Configure multiple accounts, Outlook OAuth2 and send approval.
16
+ - Turn new-mail popups off in settings; the preference survives restarts.
14
17
  - Prepare and edit local drafts, review To/Cc, content and attachment bytes, then confirm sending.
15
18
  - Manage trusted recipients per account and preview address, group and Cc matches.
16
19
 
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [English](README.en.md)
4
4
 
5
+ ![dsh-email 鲸鱼娘插件封面](https://raw.githubusercontent.com/STARDUSTLC666/dsh-email/main/assets/cover-whale-girl.png)
6
+
5
7
  在 DSH 中收发、搜索和整理邮件,支持多个邮箱账号。
6
8
 
7
9
  [![npm](https://img.shields.io/npm/v/dsh-email)](https://www.npmjs.com/package/dsh-email) [![downloads](https://img.shields.io/npm/dm/dsh-email)](https://www.npmjs.com/package/dsh-email)
@@ -11,6 +13,7 @@
11
13
  - 通过 IMAP / SMTP 收发、回复和转发邮件。
12
14
  - 管理附件、搜索结果与邮箱整理操作。
13
15
  - 多账号设置,支持 Outlook OAuth2 和发信审批。
16
+ - 新邮件弹窗可在设置中关闭,保存后重启仍生效。
14
17
  - 准备和编辑本地草稿,核对发件人、To/Cc、正文与附件预览后再确认发送。
15
18
  - 按账号管理可信收件人,预览规则如何匹配地址、地址组和抄送。
16
19
 
package/docs/USAGE.en.md CHANGED
@@ -24,6 +24,8 @@ See the [draft workbench](DRAFTS.en.md) for editable drafts and per-account trus
24
24
 
25
25
  Once an account is configured, a "whale-girl courier" popup lives in the bottom-right corner of the main UI: it checks for new mail every 30 seconds and shows a card (sender + subject) when something arrives, auto-dismissing after 12 seconds. The popup shares the cursor logic with `email_watch` but keeps its own counter, so they never consume each other's mail.
26
26
 
27
+ In Settings → Email → Accounts, turn off **Show new-mail popups** to hide the current popup and stop automatic mail checks. This setting saves automatically and survives a restart; it defaults to on. Re-enabling first seeds a fresh baseline, without replaying mail from the disabled period. Checks pause while the page is hidden. A change saved in another page takes effect on the next local-settings refresh. Conversation tools, including `email_watch`, remain available.
28
+
27
29
  The popup artwork prefers the locally installed [dsh-deep-whale](https://github.com/Small-tailqwq/dsh-deep-whale) whale-girl skin assets (**never bundled** — read at runtime from your own installation): the artwork is a derivative of the original whale-girl character by [上善](https://www.pixiv.net/users/62155430) (skin by Small-tailqwq), published under CC BY-NC-SA 4.0 (Attribution-NonCommercial-ShareAlike); the popup shows the full attribution chain. Without the skin, a bundled community whale-girl artwork is used (copyright stays with the original author, personal non-commercial use only; removed on request via issue).
28
30
 
29
31
  Example:
package/docs/USAGE.md CHANGED
@@ -24,6 +24,8 @@
24
24
 
25
25
  配置好账号后,主界面右下角会出现「鲸鱼娘递信」小弹窗:每 30 秒检查一次新邮件,有新邮件时弹出卡片(发件人 + 主题),12 秒自动消失。弹窗与 `email_watch` 工具共用同一套游标逻辑但各自独立计数,互不抢占。
26
26
 
27
+ 在「设置 → 邮件 → 邮箱账号」取消勾选「显示新邮件弹窗」,立即隐藏当前弹窗并停止自动检查;设置会自动保存,重启后仍生效。默认开启。再次开启时先建立新的基线,不补弹关闭期间的旧邮件。页面隐藏时暂停检查;另一个页面保存的开关在下一次本地设置刷新时生效。对话中的 `email_watch` 等邮件工具照常可用。
28
+
27
29
  弹窗形象优先使用本地安装的 [dsh-deep-whale](https://github.com/Small-tailqwq/dsh-deep-whale) 鲸鱼娘皮肤素材(**不打包分发**,运行时从你自己的安装目录读取):该素材为一创 [上善](https://www.pixiv.net/users/62155430) 鲸鱼娘形象的衍生创作(二创 Small-tailqwq),以 CC BY-NC-SA 4.0(署名-非商业性使用-相同方式共享)发布,弹窗内附完整署名链。未安装皮肤时使用内置的社区鲸鱼娘形象(版权归原作者,仅供个人非商业使用;如有异议请提 Issue,会立即移除)。
28
30
 
29
31
  示例对话:
@@ -1,5 +1,9 @@
1
1
  # dsh-email 验证记录
2
2
 
3
+ [0.15.3 通知开关验收](validation/0.15.3.md)。
4
+
5
+ [0.15.2 中英文界面验收](validation-language-2026-10-03.md)。
6
+
3
7
  ## 0.15.1(2026-10-03)
4
8
 
5
9
  Windows / Node 24.16.0 下 330 项测试全部通过,无跳过。新回归在修复前定位到 email_draft 的 maxItems 参数声明,修复后通过;11 个附件仍在读取文件、保存草稿或发送之前被拒绝。
@@ -0,0 +1,7 @@
1
+ # 0.15.3 通知开关验收 · 2026-10-04
2
+
3
+ Windows / Node 24.16.0:334 项测试全部通过。新增回归覆盖默认开启、持久化、不重建连接池、关闭时停止自动检查、跨页面重新开启、静默基线,以及忽略取消信号的迟到响应在关闭或卸载后不能复活弹窗。
4
+
5
+ 在官方源码构建的 DSH 0.2.0-rc.2(639ed01539)独立 profile 中操作了中文和英文设置页,关闭开关、自动保存、键盘 Space 重新开启及页面重新加载。主界面实际显示了合成新邮件弹窗,再从邮件设置关闭开关,卡片被移除。设置写入独立配置文件,测试账号和合成新邮件不含真实邮箱凭据。
6
+
7
+ 网络和通知测试不发送邮件,也不改变对话中的邮件工具权限。真实邮箱登录、SMTP 投递及原生桌面窗口未在本轮验收;网页通过不能替代原生桌面验收。
@@ -0,0 +1,16 @@
1
+ # 0.15.2:中英文界面验收
2
+
3
+ 2026-10-03,Windows / Node 24.16.0。采用 E 盘独立 DSH_HOME、合成资料与无密钥 profile;用户资料、账号、密钥、桌面窗口与 VPN 保持原状。
4
+
5
+ - 自动测试:331 项全部通过。
6
+ - 可见 Web 操作:英文界面新建并保存中文草稿,未配置账号时的预览引导为英文;中文界面读取同一份草稿,标题和正文保留。语言菜单回调顺序及发送结果不明的防重发提示有回归覆盖。
7
+ - 宿主:官方 DSH 0.2.1-alpha.1 Web;官方 RC2 与 alpha SDK 同载 18 组件,110 工具、35 插件技能、19 输出样例注册与 schema 检查通过。无账号的健康结果不代表真实连接。
8
+ - 边界:没有发送真实邮件。收件人配置与发件账号配置分别给出恢复引导。
9
+
10
+ 中英文共用同一份资料;界面翻译不会改写标题、正文、路径、引用或项目文件。系统文件选择控件和文件对话框跟随 Windows / 浏览器语言。未知技术诊断与原始引擎日志保留原文;英文界面提供操作恢复引导。
11
+
12
+ 此记录不代表原生桌面窗口内的新功能已逐项操作通过。发布包完整性、GitHub CI 与公开 npm 全新安装在发布时另行核对。
13
+
14
+ ## English scope
15
+
16
+ The Chinese and English UI share the same data. UI translation preserves original content, paths and project files. Visible interactions were performed in an isolated official alpha Web profile; both RC2 and alpha SDK registration/schema checks passed. Native file controls use the OS/browser language, and original diagnostic logs are retained. Browser results do not certify native desktop interaction or real-service delivery.
package/lib/client.js CHANGED
@@ -12,10 +12,27 @@ const h = React.createElement;
12
12
 
13
13
  // Harness supplies document.baseURI for reverse-proxy mounts and in-page navigation.
14
14
  const ROUTE = "_dsh/dsh-email/settings";
15
+
16
+ function emailError(error) {
17
+ const message = typeof error === 'string' ? error : error?.message || String(error || '');
18
+ if (UI_LANG !== 'en') return message;
19
+ if (error?.status === 401) return 'Your DSH session expired. Reopen DSH and try again.';
20
+ if (!/[\u3400-\u9fff]/.test(message)) return message;
21
+ if (/结果.*未|不要.*重发|不要.*发送|本地保存失败|回执未能保存/.test(message)) return 'The send outcome is uncertain or its receipt could not be saved. Check the mailbox before sending again to avoid duplicate delivery.';
22
+ if (/已提交|不能再次发送/.test(message)) return 'This draft was submitted or its outcome is uncertain. Check the mailbox before copying it to a new draft.';
23
+ if (/预览已过期|draft-preview-expired/.test(message + ' ' + (error?.code || ''))) return 'The send review expired or was already used. Review the current draft again.';
24
+ if (/改变|已更新|冲突|重新加载/.test(message)) return 'The draft, account or rules changed. Reload the latest version and review your input before retrying.';
25
+ if (/请先填写并保存邮件主题/.test(message)) return 'Fill in and save the email subject first.';
26
+ if (/附件/.test(message)) return 'Check the attachments. Save the draft first; use up to 10 readable files and 20 MiB total. Remove invalid files and add them again.';
27
+ if (/账号|配置不完整|尚未登录|OAuth|设备码/.test(message)) return 'Check the selected email account. Complete its credentials or OAuth sign-in in Account settings, then test the connection.';
28
+ if (/收件人|邮箱地址/.test(message)) return 'Check the recipient addresses and trusted-recipient rules before reviewing the draft again.';
29
+ return 'The email operation failed. Check the account settings and DSH logs, then retry. For a send operation, check the mailbox before retrying.';
30
+ }
31
+
15
32
  /** 「编辑即保存」的防抖窗口:连续打字只落一次盘。 */
16
33
  const SAVE_DEBOUNCE_MS = 800;
17
34
 
18
- async function api(action, payload) {
35
+ async function api(action, payload, signal) {
19
36
  const init = action === undefined
20
37
  ? { credentials: "same-origin" }
21
38
  : {
@@ -24,7 +41,7 @@ async function api(action, payload) {
24
41
  headers: { "Content-Type": "application/json" },
25
42
  body: JSON.stringify(Object.assign({ action }, payload)),
26
43
  };
27
- const res = await fetch(ROUTE, init);
44
+ const res = await fetch(ROUTE, { ...init, signal });
28
45
  const body = await res.json();
29
46
  if (!res.ok || !body.ok) {
30
47
  const error = new Error((body && body.error && body.error.message) || ("request failed " + res.status));
@@ -32,6 +49,7 @@ async function api(action, payload) {
32
49
  // anything else is not, and the message alone cannot tell them apart.
33
50
  error.code = body && body.error ? body.error.code : undefined;
34
51
  error.status = res.status;
52
+ error.message = emailError(error);
35
53
  throw error;
36
54
  }
37
55
  return body.value;
@@ -41,8 +59,8 @@ async function api(action, payload) {
41
59
  * 登录失败的文案:平铺的 message 优先,退回 error.message,再退回调用方给的兜底。
42
60
  */
43
61
  function oauthErrorMessage(body, fallback) {
44
- if (body && typeof body.message === "string" && body.message !== "") return body.message;
45
- if (body && body.error && typeof body.error.message === "string" && body.error.message !== "") return body.error.message;
62
+ if (body && typeof body.message === "string" && body.message !== "") return emailError(body.message);
63
+ if (body && body.error && typeof body.error.message === "string" && body.error.message !== "") return emailError(body.error);
46
64
  return fallback;
47
65
  }
48
66
 
@@ -120,6 +138,7 @@ const EMPTY = {
120
138
  password: "",
121
139
  inboxFolder: "INBOX",
122
140
  sendApproval: true,
141
+ newMailPopup: true,
123
142
  downloadDir: "",
124
143
  accountsYaml: "",
125
144
  serverPresets: "",
@@ -272,7 +291,7 @@ var UI = {
272
291
  "global.sendApproval": "发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
273
292
  "global.downloadDirLabel": "附件下载目录(默认保存在当前工作区)",
274
293
  "global.downloadDirPlaceholder": "留空使用默认",
275
- "global.hint": "以上两项对所有账号统一生效。",
294
+ "global.hint": "以上设置对所有账号统一生效。",
276
295
 
277
296
  // 自动保存状态条
278
297
  "status.saved": "已保存",
@@ -453,7 +472,7 @@ var UI = {
453
472
  "global.sendApproval": "Ask for confirmation before sending (strongly recommended; auto-denied under Full Access)",
454
473
  "global.downloadDirLabel": "Attachment download directory (defaults to the current workspace)",
455
474
  "global.downloadDirPlaceholder": "Leave empty for the default",
456
- "global.hint": "These two settings apply to every account.",
475
+ "global.hint": "These settings apply to every account.",
457
476
 
458
477
  // 自动保存状态条
459
478
  "status.saved": "Saved",
@@ -620,6 +639,7 @@ function localeToLang(active) {
620
639
  * 弹窗,所以这里是单槽而不是列表;弹窗销毁时它会把自己的钩子摘掉。
621
640
  */
622
641
  var relocalize = null;
642
+ var activeLocaleService = null;
623
643
 
624
644
  /**
625
645
  * 取 locale 服务。两条路都试,因为「服务在不在」和「ctx 长什么样」是两件事:
@@ -650,6 +670,7 @@ function localeServiceOf(ctx) {
650
670
  */
651
671
  function subscribeLocale(ctx) {
652
672
  const service = localeServiceOf(ctx);
673
+ activeLocaleService = service;
653
674
  if (service === null) return () => {};
654
675
  const apply = () => {
655
676
  try {
@@ -1257,7 +1278,7 @@ function OauthLoginPanel(props) {
1257
1278
 
1258
1279
  if (state === "error") {
1259
1280
  return h("div", { className: "dshe-oauth" }, [
1260
- h("div", { className: "dshe-alert error" }, String(session.message || t("oauth.loginFailed"))),
1281
+ h("div", { className: "dshe-alert error" }, emailError(session.message || t("oauth.loginFailed"))),
1261
1282
  clientIdField(),
1262
1283
  h("div", { className: "dshe-oauth-actions" }, [
1263
1284
  h("button", {
@@ -1379,7 +1400,7 @@ function AccountCardsEditor(props) {
1379
1400
  try {
1380
1401
  built = names.map((name) => draftToInput(draftsNow[name]));
1381
1402
  } catch (e) {
1382
- incomplete(e && e.message ? e.message : String(e));
1403
+ incomplete(emailError(e));
1383
1404
  return null;
1384
1405
  }
1385
1406
  if (built.some((card) => String(card.name || "").trim() === "")) {
@@ -1486,7 +1507,7 @@ function AccountCardsEditor(props) {
1486
1507
  } catch (e) {
1487
1508
  if (seq !== oauthCurrent(name)) return;
1488
1509
  setRowStatus((cur) => { const next = Object.assign({}, cur); delete next[name]; return next; });
1489
- setOauthOf(name, { state: "error", message: e && e.message ? e.message : String(e) });
1510
+ setOauthOf(name, { state: "error", message: emailError(e) });
1490
1511
  }
1491
1512
  };
1492
1513
 
@@ -1498,7 +1519,7 @@ function AccountCardsEditor(props) {
1498
1519
  body = await apiOauth("oauthPoll", { account: name });
1499
1520
  } catch (e) {
1500
1521
  if (seq !== oauthCurrent(name)) return;
1501
- setOauthOf(name, { state: "error", message: e && e.message ? e.message : String(e) });
1522
+ setOauthOf(name, { state: "error", message: emailError(e) });
1502
1523
  return;
1503
1524
  }
1504
1525
  if (seq !== oauthCurrent(name)) return;
@@ -1635,7 +1656,7 @@ function AccountCardsEditor(props) {
1635
1656
  }));
1636
1657
  } catch (e) {
1637
1658
  setRowStatus((cur) => Object.assign({}, cur, {
1638
- [draftName]: { kind: "error", text: t("card.testFailed", { message: e && e.message ? e.message : String(e) }) },
1659
+ [draftName]: { kind: "error", text: t("card.testFailed", { message: emailError(e) }) },
1639
1660
  }));
1640
1661
  } finally {
1641
1662
  setBusy("");
@@ -2148,7 +2169,7 @@ async function draftApi(action, payload, upload) {
2148
2169
  init.body = upload;
2149
2170
  } else init.body = JSON.stringify({ ...payload, action });
2150
2171
  const response = await fetch(upload ? DRAFT_ROUTE + "/upload" : DRAFT_ROUTE, init), result = await response.json();
2151
- if (!response.ok || !result.ok) { const error = new Error(result.error && result.error.message || dt("邮件操作失败", "Email action failed")); error.code = result.error && result.error.code; throw error; }
2172
+ if (!response.ok || !result.ok) { const error = new Error(result.error && result.error.message || dt("邮件操作失败", "Email action failed")); error.code = result.error && result.error.code; error.status = response.status; error.message = emailError(error); throw error; }
2152
2173
  return result.value;
2153
2174
  }
2154
2175
  const draftForm = (draft) => ({ account: draft.account || "", to: draft.to || "", cc: draft.cc || "", subject: draft.subject || "", text: draft.text || "", attachments: draft.attachments.map(file => file.id) });
@@ -2280,6 +2301,11 @@ function EmailSettingsSection() {
2280
2301
  const [tab, setTab] = useState(emailActiveTab);
2281
2302
  useEffect(() => { emailActiveTab = tab; }, [tab]);
2282
2303
  const [draft, setDraft] = useState(null);
2304
+ useEffect(() => {
2305
+ if (draft === null) return undefined;
2306
+ updateWhalePreference?.(draft.newMailPopup !== false);
2307
+ return () => updateWhalePreference?.(null);
2308
+ }, [draft?.newMailPopup]);
2283
2309
  const [snapshot, setSnapshot] = useState(undefined);
2284
2310
  const [busy, setBusy] = useState(false);
2285
2311
  const [error, setError] = useState("");
@@ -2347,7 +2373,7 @@ function EmailSettingsSection() {
2347
2373
  setCardsDetail(snap.accountsDetail);
2348
2374
  setPresetsAtLoad(snap.presets);
2349
2375
  } catch (e) {
2350
- setError(e && e.message ? e.message : String(e));
2376
+ setError(emailError(e));
2351
2377
  } finally {
2352
2378
  setBusy(false);
2353
2379
  }
@@ -2425,7 +2451,7 @@ function EmailSettingsSection() {
2425
2451
  if (seq !== seqRef.current) return;
2426
2452
  // 失败时清掉「正在飞」的签名,否则同一个值再也不会被重排(点重试即可再存)。
2427
2453
  pendingSignatureRef.current = null;
2428
- setSaveState({ kind: "error", text: e && e.message ? e.message : String(e) });
2454
+ setSaveState({ kind: "error", text: emailError(e) });
2429
2455
  }
2430
2456
  };
2431
2457
 
@@ -2515,7 +2541,7 @@ function EmailSettingsSection() {
2515
2541
  return { ok: true };
2516
2542
  } catch (e) {
2517
2543
  if (seq !== cardsSeqRef.current) return { ok: false, reason: "superseded" };
2518
- const message = e && e.message ? e.message : String(e);
2544
+ const message = emailError(e);
2519
2545
  setCardsNotice({ kind: "error", text: t("notice.cardsNotWritten", { message }) });
2520
2546
  setSaveState({ kind: "error", text: message });
2521
2547
  return { ok: false, reason: message };
@@ -2572,6 +2598,16 @@ function EmailSettingsSection() {
2572
2598
  // 全局设置:发信确认和下载目录对所有账号一视同仁,不属于任何一张卡片,
2573
2599
  // 所以摆在卡片列表上方,而不是塞进某一张卡里。
2574
2600
  h("div", { className: "dshe-global" }, [
2601
+ h("label", { className: "dshe-check" }, [
2602
+ h("input", {
2603
+ type: "checkbox",
2604
+ checked: draft.newMailPopup !== false,
2605
+ disabled: snapshot?.writable === false,
2606
+ onChange: (e) => update({ newMailPopup: e.target.checked }),
2607
+ }),
2608
+ dt("显示新邮件弹窗", "Show new-mail popups"),
2609
+ ]),
2610
+ h("div", { className: "dshe-hint" }, dt("关闭后立即隐藏弹窗并停止自动检查,不影响对话中手动查看邮件。重新开启时从当前邮件开始提醒。", "Turning this off hides the popup and stops automatic mail checks. You can still check mail in a conversation. Turning it back on starts with a fresh baseline.")),
2575
2611
  h("label", { className: "dshe-check" }, [
2576
2612
  h("input", {
2577
2613
  type: "checkbox",
@@ -2621,6 +2657,7 @@ function EmailSettingsSection() {
2621
2657
  // when newCount > 0. The artwork URL/credit come from the settings snapshot
2622
2658
  // (skin artwork is served from the user's local dsh-deep-whale install with
2623
2659
  // its CC BY-NC-SA attribution chain; otherwise a built-in fallback is used).
2660
+ let updateWhalePreference = null;
2624
2661
  function startWhaleWidget() {
2625
2662
  if (document.querySelector("#dsh-email-whale-root")) return () => {};
2626
2663
  const root = document.createElement("div");
@@ -2631,17 +2668,26 @@ function startWhaleWidget() {
2631
2668
  let hideTimer = 0;
2632
2669
  let whaleUrl = "";
2633
2670
  let whaleCredit = "";
2671
+ let enabled = true;
2672
+ let localPreference = null;
2673
+ let disposed = false;
2674
+ let running = false;
2675
+ let generation = 0;
2676
+ let seedBaseline = true;
2677
+ let watchAbort = null;
2634
2678
  /** 当前弹窗的数据。语言切了就靠它原样重画一遍,无需再拉一次接口。 */
2635
2679
  let current = null;
2636
2680
 
2637
2681
  const closePopup = () => {
2638
2682
  if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0; }
2639
2683
  root.replaceChildren();
2684
+ current = null;
2640
2685
  };
2641
2686
 
2642
2687
  const showPopup = (value) => {
2643
- current = value;
2644
2688
  closePopup();
2689
+ if (disposed || !enabled) return;
2690
+ current = value;
2645
2691
  const card = document.createElement("div");
2646
2692
  card.className = "dshe-whale-card";
2647
2693
  if (whaleUrl) {
@@ -2689,46 +2735,63 @@ function startWhaleWidget() {
2689
2735
  };
2690
2736
 
2691
2737
  // 轮询策略:页面不可见时完全不打扰邮箱服务器;失败指数退避(上限 10 分钟),
2692
- // 成功立刻回到 30s;快照(皮肤 / 账号列表)变化很慢,最多 10 分钟刷新一次。
2738
+ // 成功立刻回到 30s;本地设置快照同样刷新,以便跨页面的通知开关生效。
2693
2739
  const POLL_MS = 30000;
2694
- const SNAPSHOT_MS = 600000;
2695
2740
  const MAX_BACKOFF_MS = 600000;
2696
2741
  let failures = 0;
2697
- let lastSnapshot = 0;
2698
- let hasAccounts = true;
2742
+ const setEnabled = (value) => {
2743
+ if (value === enabled) return;
2744
+ enabled = value;
2745
+ generation++;
2746
+ seedBaseline = true;
2747
+ watchAbort?.abort();
2748
+ if (!enabled) closePopup();
2749
+ };
2750
+ updateWhalePreference = value => {
2751
+ localPreference = value;
2752
+ if (value !== null) setEnabled(value);
2753
+ };
2699
2754
 
2700
2755
  const tick = async () => {
2701
- if (document.hidden) return;
2756
+ if (disposed || document.hidden || running) return;
2757
+ running = true;
2702
2758
  try {
2703
- if (!hasAccounts || Date.now() - lastSnapshot > SNAPSHOT_MS) {
2704
- const snap = await api();
2705
- lastSnapshot = Date.now();
2706
- if (snap && snap.whale) {
2707
- whaleUrl = (snap.whale.url || "").replace(/^\/_dsh\//, "_dsh/");
2708
- whaleCredit = snap.whale.credit || "";
2709
- }
2710
- hasAccounts = !!(snap && snap.accounts && snap.accounts.length > 0);
2759
+ // Refresh the small local snapshot even when disabled so another tab's
2760
+ // saved preference takes effect. This does not contact the mail server.
2761
+ const snap = await api();
2762
+ if (disposed) return;
2763
+ setEnabled(localPreference ?? (snap?.settings?.value?.newMailPopup !== false));
2764
+ if (snap && snap.whale) {
2765
+ whaleUrl = (snap.whale.url || "").replace(/^\/_dsh\//, "_dsh/");
2766
+ whaleCredit = snap.whale.credit || "";
2711
2767
  }
2712
- if (!hasAccounts) return;
2713
- const value = await api("watch", { limit: 5 });
2714
- if (value && value.newCount > 0) showPopup(value);
2768
+ if (!enabled || !snap?.accounts?.length || document.hidden) { failures = 0; return; }
2769
+ const version = generation;
2770
+ watchAbort = new AbortController();
2771
+ const value = await api("watch", { limit: 5 }, watchAbort.signal);
2772
+ if (disposed || !enabled || version !== generation || document.hidden) return;
2773
+ if (!seedBaseline && value && value.newCount > 0) showPopup(value);
2774
+ seedBaseline = false;
2715
2775
  failures = 0;
2716
2776
  } catch (e) {
2717
2777
  // Not configured or transient error: stay silent and back off.
2718
2778
  failures = Math.min(failures + 1, 8);
2779
+ } finally {
2780
+ running = false;
2781
+ watchAbort = null;
2719
2782
  }
2720
2783
  };
2721
2784
 
2722
2785
  const schedule = () => {
2723
2786
  clearTimeout(pollTimer);
2724
- if (document.hidden) return; // 回到前台时 onVisibility 会立刻补一次
2787
+ if (disposed || document.hidden) return;
2725
2788
  const delay = failures === 0 ? POLL_MS : Math.min(POLL_MS * Math.pow(2, failures - 1), MAX_BACKOFF_MS);
2726
2789
  pollTimer = setTimeout(() => { void tick().then(schedule); }, delay);
2727
2790
  };
2728
2791
 
2729
2792
  const onVisibility = () => {
2730
2793
  clearTimeout(pollTimer);
2731
- if (document.hidden) return;
2794
+ if (disposed || document.hidden) { watchAbort?.abort(); closePopup(); return; }
2732
2795
  failures = 0;
2733
2796
  void tick().then(schedule);
2734
2797
  };
@@ -2737,10 +2800,14 @@ function startWhaleWidget() {
2737
2800
  // 弹窗是命令式 DOM,没有 React 那层重渲染:语言切换后自己重画一次(开着的才重画)。
2738
2801
  relocalize = () => { if (current !== null && root.childElementCount > 0) showPopup(current); };
2739
2802
  return () => {
2803
+ disposed = true;
2804
+ generation++;
2805
+ watchAbort?.abort();
2740
2806
  clearTimeout(pollTimer);
2741
2807
  document.removeEventListener("visibilitychange", onVisibility);
2742
2808
  if (hideTimer) clearTimeout(hideTimer);
2743
2809
  relocalize = null;
2810
+ updateWhalePreference = null;
2744
2811
  root.remove();
2745
2812
  };
2746
2813
  }
@@ -2776,7 +2843,11 @@ function apply(ctx) {
2776
2843
  name: "settings.section",
2777
2844
  id: "dsh-email",
2778
2845
  order: 45,
2779
- label: () => t("nav.title"),
2846
+ label: () => {
2847
+ let lang = UI_LANG;
2848
+ try { if (activeLocaleService?.getSnapshot) lang = localeToLang(activeLocaleService.getSnapshot().active); } catch {}
2849
+ return UI[lang]["nav.title"];
2850
+ },
2780
2851
  inject: () => ({}),
2781
2852
  }, EmailSettingsSection));
2782
2853
  }
package/lib/config.d.ts CHANGED
@@ -108,6 +108,8 @@ export interface AccountConfig {
108
108
  inboxFolder?: string;
109
109
  }
110
110
  export interface EmailConfig extends AccountConfig {
111
+ /** Show browser new-mail notifications. UI policy only; default true. */
112
+ newMailPopup?: boolean;
111
113
  /** Per-account recipient rules, independent of IMAP/SMTP connection settings. */
112
114
  trustedRecipientsYaml?: string;
113
115
  /** Ask the user for approval before email_send. Default true. */
@@ -12,6 +12,7 @@ export const Config = z.object({
12
12
  clientId: z.string().volatile(), authKind: z.union(['password', 'oauth2']).volatile(),
13
13
  imap: endpoint().volatile(), smtp: endpoint().volatile(),
14
14
  inboxFolder: z.string().volatile(), sendApproval: z.boolean().volatile(),
15
+ newMailPopup: z.boolean().volatile(),
15
16
  maxBodyChars: z.number().volatile(), downloadDir: z.string().volatile(),
16
17
  accounts: z.dict(z.any()).role('secret').volatile(), defaultAccount: z.string().volatile(),
17
18
  accountsYaml: z.string().role('secret').volatile(), serverPresets: z.string().volatile(),
package/lib/settings.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare const EmailSettingsSchema: import("@deepseek-ai/schemastery").def
12
12
  password: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
13
13
  inboxFolder: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
14
14
  sendApproval: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
15
+ newMailPopup: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
15
16
  trustedRecipientsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
16
17
  maxBodyChars: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
17
18
  downloadDir: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
@@ -41,6 +42,7 @@ export declare const EmailSettingsSchema: import("@deepseek-ai/schemastery").def
41
42
  password: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
42
43
  inboxFolder: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
43
44
  sendApproval: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
45
+ newMailPopup: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
44
46
  trustedRecipientsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
45
47
  maxBodyChars: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
46
48
  downloadDir: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
@@ -71,6 +73,7 @@ export interface EmailSettingsValue {
71
73
  password: string;
72
74
  inboxFolder: string;
73
75
  sendApproval: boolean;
76
+ newMailPopup?: boolean;
74
77
  trustedRecipientsYaml?: string;
75
78
  maxBodyChars: number;
76
79
  downloadDir: string;
package/lib/settings.js CHANGED
@@ -14,6 +14,7 @@ export const EmailSettingsSchema = z.object({
14
14
  password: z.string().role('secret').default(''),
15
15
  inboxFolder: z.string().default('INBOX'),
16
16
  sendApproval: z.boolean().default(true),
17
+ newMailPopup: z.boolean().default(true),
17
18
  trustedRecipientsYaml: z.string().default(''),
18
19
  maxBodyChars: z.number().default(20000),
19
20
  downloadDir: z.string().default(''),
@@ -41,6 +42,7 @@ export function toSettingsBase(config) {
41
42
  ...(config.password !== undefined && config.password !== '' ? { password: config.password } : {}),
42
43
  ...(config.inboxFolder !== undefined && config.inboxFolder !== '' ? { inboxFolder: config.inboxFolder } : {}),
43
44
  ...(config.sendApproval !== undefined ? { sendApproval: config.sendApproval } : {}),
45
+ ...(config.newMailPopup !== undefined ? { newMailPopup: config.newMailPopup } : {}),
44
46
  ...(config.trustedRecipientsYaml !== undefined ? { trustedRecipientsYaml: config.trustedRecipientsYaml } : {}),
45
47
  ...(config.maxBodyChars !== undefined ? { maxBodyChars: config.maxBodyChars } : {}),
46
48
  ...(config.downloadDir !== undefined && config.downloadDir !== '' ? { downloadDir: config.downloadDir } : {}),
@@ -203,6 +205,9 @@ function describeValue(value) {
203
205
  */
204
206
  export function validateSettingsValue(value, extraProviders = []) {
205
207
  const draft = (value ?? {});
208
+ if (draft.newMailPopup !== undefined && typeof draft.newMailPopup !== 'boolean') {
209
+ throw new Error('新邮件弹窗开关必须为布尔值');
210
+ }
206
211
  if (draft.trustedRecipientsYaml !== undefined) {
207
212
  if (typeof draft.trustedRecipientsYaml !== 'string')
208
213
  throw new Error('可信收件人规则必须为文本');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "在 DSH 中收发、搜索和整理邮件,支持多个邮箱账号。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",