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 +11 -0
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/docs/USAGE.en.md +2 -0
- package/docs/USAGE.md +2 -0
- package/docs/VALIDATION.md +4 -0
- package/docs/validation/0.15.3.md +7 -0
- package/docs/validation-language-2026-10-03.md +16 -0
- package/lib/client.js +106 -35
- package/lib/config.d.ts +2 -0
- package/lib/host-config.js +1 -0
- package/lib/settings.d.ts +3 -0
- package/lib/settings.js +5 -0
- package/package.json +1 -1
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
|
+

|
|
6
|
+
|
|
5
7
|
Read, send, search and organize email from DSH with multiple accounts.
|
|
6
8
|
|
|
7
9
|
[](https://www.npmjs.com/package/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
|
+

|
|
6
|
+
|
|
5
7
|
在 DSH 中收发、搜索和整理邮件,支持多个邮箱账号。
|
|
6
8
|
|
|
7
9
|
[](https://www.npmjs.com/package/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
|
示例对话:
|
package/docs/VALIDATION.md
CHANGED
|
@@ -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
|
|
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
|
|
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" },
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2698
|
-
|
|
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
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
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 (!
|
|
2713
|
-
const
|
|
2714
|
-
|
|
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;
|
|
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: () =>
|
|
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. */
|
package/lib/host-config.js
CHANGED
|
@@ -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('可信收件人规则必须为文本');
|