dsh-email 0.15.2 → 0.16.0

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,16 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.16.0 (2026-10-05)
8
+
9
+ - 增加每账号收件人禁止地址与禁止域名;禁止规则优先于可信名单,并在所有发信路径的 SMTP 连接前拦截。草稿预览显示阻止原因并保留可编辑草稿。
10
+
11
+ ## 0.15.3 (2026-10-04)
12
+
13
+ - 完成 [#21](https://github.com/STARDUSTLC666/dsh-email/issues/21):邮箱账号页新增「显示新邮件弹窗」开关,自动保存、重启后保留。关闭时隐藏当前卡片并停止自动邮件检查,不影响手动邮件工具。
14
+ - 重新开启建立新基线,不补弹关闭期间的旧邮件;跨页面保存的开关在下一次本地设置刷新时生效。关闭或卸载后,迟到的网络响应不能重新显示弹窗。
15
+ - 验收范围见 [0.15.3 通知开关检查](docs/validation/0.15.3.md)。
16
+
7
17
  ## 0.15.2 (2026-10-03)
8
18
 
9
19
  - 修复语言切换后设置菜单滞后一轮;英文草稿、账号、发送失败引导不再回退中文,保留结果不明时禁止盲目重发的提示。
package/README.en.md CHANGED
@@ -2,15 +2,18 @@
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
- [![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)
9
+ [![npm](https://img.shields.io/npm/v/dsh-email)](https://www.npmjs.com/package/dsh-email) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-email-downloads.svg)](https://www.npmjs.com/package/dsh-email)
8
10
 
9
11
  ## What it does
10
12
 
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,15 +2,18 @@
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
- [![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)
9
+ [![npm](https://img.shields.io/npm/v/dsh-email)](https://www.npmjs.com/package/dsh-email) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-email-downloads.svg)](https://www.npmjs.com/package/dsh-email)
8
10
 
9
11
  ## 功能
10
12
 
11
13
  - 通过 IMAP / SMTP 收发、回复和转发邮件。
12
14
  - 管理附件、搜索结果与邮箱整理操作。
13
15
  - 多账号设置,支持 Outlook OAuth2 和发信审批。
16
+ - 新邮件弹窗可在设置中关闭,保存后重启仍生效。
14
17
  - 准备和编辑本地草稿,核对发件人、To/Cc、正文与附件预览后再确认发送。
15
18
  - 按账号管理可信收件人,预览规则如何匹配地址、地址组和抄送。
16
19
 
@@ -4,6 +4,10 @@
4
4
 
5
5
  These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
6
6
 
7
+ ## 0.16.0 (2026-10-05)
8
+
9
+ - Add per-account blocked addresses and domains. Deny rules override trust and stop every sending path before SMTP connection. Draft previews explain the block and preserve editable drafts.
10
+
7
11
  ## 0.14.7 (2026-10-02)
8
12
 
9
13
  - Account cards display the effective IMAP/SMTP endpoints. Changing providers clears stale display values without persisting the endpoint summary into account configuration.
package/docs/USAGE.en.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
4
4
 
5
+ ## Current improvements
6
+
7
+ Optionally configure blockedAddresses / blockedDomains per account. Rules cover To and Cc and remain active with sendApproval=off. Domains match exactly, excluding subdomains unless listed. Remove blocked recipients and preview again before sending.
8
+
5
9
  ## Tools
6
10
 
7
11
  See the [draft workbench](DRAFTS.en.md) for editable drafts and per-account trusted recipients. The new workbench was validated with official Harness 0.2.0-rc.2.
@@ -24,6 +28,8 @@ See the [draft workbench](DRAFTS.en.md) for editable drafts and per-account trus
24
28
 
25
29
  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
30
 
31
+ 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.
32
+
27
33
  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
34
 
29
35
  Example:
package/docs/USAGE.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
4
 
5
+ ## 本次改进
6
+
7
+ 设置页可选填 blockedAddresses / blockedDomains。禁止规则覆盖 To、Cc,sendApproval=off 时仍生效。域名按完整域名匹配,不自动包含子域名。移除被禁止的收件人并重新预览后才能确认发送。
8
+
5
9
  ## 工具一览
6
10
 
7
11
  可编辑草稿和按账号的可信收件人规则见[草稿工作台](DRAFTS.md)。新工作台已用官方 Harness 0.2.0-rc.2 验收。
@@ -24,6 +28,8 @@
24
28
 
25
29
  配置好账号后,主界面右下角会出现「鲸鱼娘递信」小弹窗:每 30 秒检查一次新邮件,有新邮件时弹出卡片(发件人 + 主题),12 秒自动消失。弹窗与 `email_watch` 工具共用同一套游标逻辑但各自独立计数,互不抢占。
26
30
 
31
+ 在「设置 → 邮件 → 邮箱账号」取消勾选「显示新邮件弹窗」,立即隐藏当前弹窗并停止自动检查;设置会自动保存,重启后仍生效。默认开启。再次开启时先建立新的基线,不补弹关闭期间的旧邮件。页面隐藏时暂停检查;另一个页面保存的开关在下一次本地设置刷新时生效。对话中的 `email_watch` 等邮件工具照常可用。
32
+
27
33
  弹窗形象优先使用本地安装的 [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
34
 
29
35
  示例对话:
@@ -1,5 +1,7 @@
1
1
  # dsh-email 验证记录
2
2
 
3
+ [0.15.3 通知开关验收](validation/0.15.3.md)。
4
+
3
5
  [0.15.2 中英文界面验收](validation-language-2026-10-03.md)。
4
6
 
5
7
  ## 0.15.1(2026-10-03)
@@ -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 投递及原生桌面窗口未在本轮验收;网页通过不能替代原生桌面验收。
package/lib/approval.js CHANGED
@@ -4,8 +4,6 @@ export function installSendApproval(ctx, runtime) {
4
4
  if (exec?.name !== 'email_send' && exec?.name !== 'email_reply')
5
5
  return next();
6
6
  const value = runtime.getSettingsValue();
7
- if (value.sendApproval === false)
8
- return next();
9
7
  try {
10
8
  runtime.getEffectiveSettings();
11
9
  }
@@ -18,12 +16,19 @@ export function installSendApproval(ctx, runtime) {
18
16
  const settings = runtime.getEffectiveSettings(), account = typeof args.account === 'string' && args.account.trim() ? args.account.trim() : settings.defaultAccount;
19
17
  if (settings.accounts.has(account) && typeof args.to === 'string' && (args.cc === undefined || typeof args.cc === 'string')) {
20
18
  const policy = parseRecipientPolicies(value.trustedRecipientsYaml).get(account);
21
- if (policy?.skipApproval && (await matchRecipients(args.to, args.cc ?? '', policy)).skipsApproval)
22
- return next();
19
+ if (policy) {
20
+ const matching = await matchRecipients(args.to, args.cc ?? '', policy);
21
+ if (matching.blocked)
22
+ return { kind: 'deny', reason: '收件人被禁止规则拦截,邮件未发送:' + matching.rows.filter(row => row.blocked).map(row => row.address).join(', ') };
23
+ if (matching.skipsApproval)
24
+ return next();
25
+ }
23
26
  }
24
27
  }
25
28
  catch { /* Invalid rules or addresses keep the usual approval; never grant trust on a parse failure. */ }
26
29
  }
30
+ if (value.sendApproval === false)
31
+ return next();
27
32
  let reason;
28
33
  if (exec.name === 'email_send') {
29
34
  const args = (exec.arguments ?? {});
package/lib/client.js CHANGED
@@ -32,7 +32,7 @@ function emailError(error) {
32
32
  /** 「编辑即保存」的防抖窗口:连续打字只落一次盘。 */
33
33
  const SAVE_DEBOUNCE_MS = 800;
34
34
 
35
- async function api(action, payload) {
35
+ async function api(action, payload, signal) {
36
36
  const init = action === undefined
37
37
  ? { credentials: "same-origin" }
38
38
  : {
@@ -41,7 +41,7 @@ async function api(action, payload) {
41
41
  headers: { "Content-Type": "application/json" },
42
42
  body: JSON.stringify(Object.assign({ action }, payload)),
43
43
  };
44
- const res = await fetch(ROUTE, init);
44
+ const res = await fetch(ROUTE, { ...init, signal });
45
45
  const body = await res.json();
46
46
  if (!res.ok || !body.ok) {
47
47
  const error = new Error((body && body.error && body.error.message) || ("request failed " + res.status));
@@ -138,6 +138,7 @@ const EMPTY = {
138
138
  password: "",
139
139
  inboxFolder: "INBOX",
140
140
  sendApproval: true,
141
+ newMailPopup: true,
141
142
  downloadDir: "",
142
143
  accountsYaml: "",
143
144
  serverPresets: "",
@@ -290,7 +291,7 @@ var UI = {
290
291
  "global.sendApproval": "发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
291
292
  "global.downloadDirLabel": "附件下载目录(默认保存在当前工作区)",
292
293
  "global.downloadDirPlaceholder": "留空使用默认",
293
- "global.hint": "以上两项对所有账号统一生效。",
294
+ "global.hint": "以上设置对所有账号统一生效。",
294
295
 
295
296
  // 自动保存状态条
296
297
  "status.saved": "已保存",
@@ -471,7 +472,7 @@ var UI = {
471
472
  "global.sendApproval": "Ask for confirmation before sending (strongly recommended; auto-denied under Full Access)",
472
473
  "global.downloadDirLabel": "Attachment download directory (defaults to the current workspace)",
473
474
  "global.downloadDirPlaceholder": "Leave empty for the default",
474
- "global.hint": "These two settings apply to every account.",
475
+ "global.hint": "These settings apply to every account.",
475
476
 
476
477
  // 自动保存状态条
477
478
  "status.saved": "Saved",
@@ -2208,7 +2209,8 @@ function DraftWorkbench({ active }) {
2208
2209
  h("h3", null, dt("发送前预览", "Review before sending")),
2209
2210
  h("p", null, dt("发件人:", "From: ") + (preview.from.name ? preview.from.name + " <" + preview.from.address + ">" : preview.from.address)),
2210
2211
  h("p", null, dt("主题:", "Subject: ") + preview.draft.subject),
2211
- h("ul", { className: "dshe-match-list" }, preview.matching.rows.map((row, index) => h("li", { key: index }, (row.field === "cc" ? "Cc: " : "To: ") + row.address + " · " + (row.matched ? dt("匹配可信规则", "Matches a trusted rule") : dt("需要确认", "Needs confirmation"))))),
2212
+ preview.matching.blocked ? h("div", { className: "dshe-alert error", role: "alert" }, dt("有收件人命中禁止规则,无法发送。请修改草稿或收件人规则。", "A recipient is blocked. Change the draft or recipient rules before sending.")) : null,
2213
+ h("ul", { className: "dshe-match-list" }, preview.matching.rows.map((row, index) => h("li", { key: index }, (row.field === "cc" ? "Cc: " : "To: ") + row.address + " · " + (row.blocked ? dt("禁止发送:", "Blocked: ") + row.value : row.matched ? dt("匹配可信规则", "Matches a trusted rule") : dt("需要确认", "Needs confirmation"))))),
2212
2214
  h("pre", null, preview.draft.text || dt("(空正文)", "(Empty body)")),
2213
2215
  preview.attachments.length ? h("ul", { className: "dshe-files" }, preview.attachments.map(file => h("li", { key: file.id }, [
2214
2216
  h("span", null, file.filename + " · " + Math.ceil(file.size / 1024) + " KiB"),
@@ -2218,7 +2220,7 @@ function DraftWorkbench({ active }) {
2218
2220
  h("label", { className: "dshe-check" }, [h("input", { type: "checkbox", checked: acknowledged, disabled: busy, onChange: event => setAcknowledged(event.target.checked) }), dt("我已核对发件人、所有收件人、正文和附件", "I checked the sender, all recipients, content and attachments")]),
2219
2221
  h("div", { className: "dshe-actions" }, [
2220
2222
  h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(async () => { await draftApi("cancelPreview", { id: selected.id }); setPreview(null); setAcknowledged(false); setNotice(dt("预览已取消,邮件未发送。", "Review cancelled. No email was sent.")); }) }, dt("取消预览", "Cancel review")),
2221
- h("button", { type: "button", className: "dshe-btn primary", disabled: busy || !acknowledged || dirty, onClick: () => run(async () => {
2223
+ h("button", { type: "button", className: "dshe-btn primary", disabled: busy || !acknowledged || dirty || preview.matching.blocked, onClick: () => run(async () => {
2222
2224
  try { const result = await draftApi("send", { id: selected.id, preview: preview.id, confirmed: true }); adopt(result.draft); }
2223
2225
  finally { setPreview(null); setAcknowledged(false); const fresh = await reload(); const current = fresh.drafts.find(draft => draft.id === selected.id); if (current && current.state !== "draft") adopt(current); }
2224
2226
  }) }, busy ? dt("正在提交…", "Submitting…") : dt("确认发送", "Confirm send")),
@@ -2264,35 +2266,38 @@ function DraftWorkbench({ active }) {
2264
2266
  }
2265
2267
 
2266
2268
  function RecipientRules({ active, onSaved }) {
2267
- const [data, setData] = useState(() => rulesUiCache && rulesUiCache.data), [account, setAccount] = useState(() => rulesUiCache && rulesUiCache.account || ""), [form, setForm] = useState(() => rulesUiCache && rulesUiCache.form || { addresses: "", domains: "", skipApproval: false });
2269
+ const [data, setData] = useState(() => rulesUiCache && rulesUiCache.data), [account, setAccount] = useState(() => rulesUiCache && rulesUiCache.account || ""), [form, setForm] = useState(() => rulesUiCache && rulesUiCache.form || { addresses: "", domains: "", denyAddresses: "", denyDomains: "", skipApproval: false });
2268
2270
  const [base, setBase] = useState(() => rulesUiCache && rulesUiCache.base || ""), [busy, setBusy] = useState(false), [error, setError] = useState(""), [notice, setNotice] = useState(""), [pending, setPending] = useState(null), [to, setTo] = useState(() => rulesUiCache && rulesUiCache.to || ""), [cc, setCc] = useState(() => rulesUiCache && rulesUiCache.cc || ""), [match, setMatch] = useState(null);
2269
2271
  const operation = useRef(false), dirty = data && JSON.stringify(form) !== base;
2270
2272
  useEffect(() => { rulesUiCache = { data, account, form, base, to, cc }; }, [data, account, form, base, to, cc]);
2271
- const choose = (next, name) => { const rule = next.policies[name] || { addresses: [], domains: [], skipApproval: false }, fields = { addresses: rule.addresses.join("\n"), domains: rule.domains.join("\n"), skipApproval: rule.skipApproval }; setAccount(name); setForm(fields); setBase(JSON.stringify(fields)); setMatch(null); setPending(null); };
2273
+ const choose = (next, name) => { const rule = next.policies[name] || { addresses: [], domains: [], skipApproval: false }, fields = { addresses: rule.addresses.join("\n"), domains: rule.domains.join("\n"), denyAddresses: (rule.denyAddresses || []).join("\n"), denyDomains: (rule.denyDomains || []).join("\n"), skipApproval: rule.skipApproval }; setAccount(name); setForm(fields); setBase(JSON.stringify(fields)); setMatch(null); setPending(null); };
2272
2274
  const run = async (fn) => { if (operation.current) return; operation.current = true; setBusy(true); setError(""); try { await fn(); } catch (e) { setError(e.message || String(e)); } finally { operation.current = false; setBusy(false); } };
2273
2275
  const load = async () => { const next = await draftApi("rules"); setData(next); choose(next, next.accounts.includes(account) ? account : next.accounts[0] || ""); };
2274
2276
  useEffect(() => { if (active && !dirty) run(load); }, [active]);
2275
2277
  useEffect(() => { const guard = event => { if (dirty) { event.preventDefault(); event.returnValue = ""; } }; window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard); }, [dirty]);
2276
- const policy = () => ({ skipApproval: form.skipApproval, addresses: form.addresses.split(/\r?\n/).map(row => row.trim()).filter(Boolean), domains: form.domains.split(/\r?\n/).map(row => row.trim()).filter(Boolean) });
2278
+ const policy = () => ({ skipApproval: form.skipApproval, ...Object.fromEntries(['addresses', 'domains', 'denyAddresses', 'denyDomains'].map(key => [key, (form[key] || '').split(/\r?\n/).map(row => row.trim()).filter(Boolean)])) });
2277
2279
  const save = async () => { const next = await draftApi("saveRules", { account, policy: policy(), revision: data.revision }); setData(next); choose(next, account); onSaved(next.revision); setNotice(dt("此账号规则已保存。", "Rules saved for this account.")); return next; };
2278
2280
  const change = patch => { setForm(current => ({ ...current, ...patch })); setMatch(null); setNotice(""); };
2279
2281
  const request = target => { if (dirty) setPending(target); else if (target.kind === "reload") run(load); else choose(data, target.name); };
2280
2282
  return h("section", { className: "dshe-panel dshe-rule-panel", "aria-label": dt("可信收件人规则", "Trusted recipients") }, [
2281
2283
  h("p", { className: "dshe-hint" }, dt("默认每次确认。主动启用后,只有该账号的全部 To/Cc 都匹配时,email_send 才跳过插件确认;宿主权限仍然生效,草稿仍需手动确认。域名规则只匹配指定域名,不包含子域名。", "Approval is the default. When enabled, email_send skips this plugin's approval only if every To/Cc address matches this account's rules. Host permissions still apply, and drafts still need manual confirmation. Domain rules exclude subdomains.")),
2284
+ h("p", { className: "dshe-hint" }, dt("禁止规则优先于可信规则,关闭发信确认也不能绕过;同样适用于草稿、回复与转发。禁止域名也仅精确匹配,不包含子域名。", "Blocked recipients take precedence over trusted rules, even with send approval off. Blocks also apply to drafts, replies and forwards. Blocked domains match exactly and exclude subdomains.")),
2282
2285
  error ? h("div", { className: "dshe-alert error", role: "alert" }, error) : null,
2283
2286
  notice ? h("div", { className: "dshe-alert success", role: "status" }, notice) : null,
2284
- data && data.sendApproval === false ? h("div", { className: "dshe-alert info" }, dt("全局发信确认目前已关闭;这份规则不会额外拦截 email_send。", "Global send approval is currently off. These rules do not add another email_send gate.")) : null,
2287
+ data && data.sendApproval === false ? h("div", { className: "dshe-alert info" }, dt("全局发信确认目前已关闭;禁止规则仍然生效。", "Global send approval is off; blocked-recipient rules still apply.")) : null,
2285
2288
  h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-account" }, dt("规则所属账号", "Account for these rules")), h("select", { id: "dshe-rule-account", value: account, disabled: busy, onChange: event => request({ kind: "account", name: event.target.value }) }, [h("option", { value: "", disabled: true }, dt("请选择已配置账号", "Choose a configured account")), ...(data ? data.accounts : []).map(name => h("option", { key: name, value: name }, name))])]),
2286
2289
  pending ? h("div", { className: "dshe-alert info" }, [dt("规则还未保存。", "Rules have unsaved edits."), h("div", { className: "dshe-actions" }, [h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => run(async () => { const next = await save(); if (pending.kind === "reload") await load(); else choose(next, pending.name); }) }, dt("保存并切换", "Save and switch")), h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => { if (pending.kind === "reload") run(load); else choose(data, pending.name); } }, dt("放弃编辑并切换", "Discard edits and switch")), h("button", { type: "button", className: "dshe-btn", onClick: () => setPending(null) }, dt("保留编辑", "Keep editing"))])]) : null,
2287
2290
  h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-addresses" }, dt("可信邮箱地址(每行一个)", "Trusted email addresses (one per line)")), h("textarea", { id: "dshe-rule-addresses", rows: 4, value: form.addresses, placeholder: "name@example.com", disabled: busy || !account, onChange: event => change({ addresses: event.target.value }) })]),
2288
2291
  h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-domains" }, dt("可信域名(每行一个,匹配该域所有邮箱)", "Trusted domains (one per line, all mailboxes in that domain)")), h("textarea", { id: "dshe-rule-domains", rows: 3, value: form.domains, placeholder: "example.com", disabled: busy || !account, onChange: event => change({ domains: event.target.value }) })]),
2292
+ h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-deny-addresses" }, dt("禁止邮箱地址(每行一个)", "Blocked email addresses (one per line)")), h("textarea", { id: "dshe-rule-deny-addresses", rows: 3, value: form.denyAddresses || '', placeholder: "blocked@example.com", disabled: busy || !account, onChange: event => change({ denyAddresses: event.target.value }) })]),
2293
+ h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-deny-domains" }, dt("禁止域名(每行一个)", "Blocked domains (one per line)")), h("textarea", { id: "dshe-rule-deny-domains", rows: 3, value: form.denyDomains || '', placeholder: "blocked.example.com", disabled: busy || !account, onChange: event => change({ denyDomains: event.target.value }) })]),
2289
2294
  h("label", { className: "dshe-check" }, [h("input", { type: "checkbox", checked: form.skipApproval, disabled: busy || !account, onChange: event => change({ skipApproval: event.target.checked }) }), dt("允许全部匹配时跳过 email_send 的插件确认", "Skip plugin approval for email_send when all addresses match")]),
2290
2295
  h("div", { className: "dshe-actions" }, [h("button", { type: "button", className: "dshe-btn primary", disabled: busy || !dirty || !account || data && !data.writable, onClick: () => run(save) }, dt("保存此账号规则", "Save rules for this account")), h("button", { type: "button", className: "dshe-btn", disabled: busy, onClick: () => request({ kind: "reload" }) }, dt("重新加载规则", "Reload rules"))]),
2291
2296
  h("div", { className: "dshe-hint", role: "status" }, dirty ? dt("未保存;匹配预览使用当前编辑的规则。", "Unsaved; matching uses the rules you are editing.") : dt("已保存的规则才会作用于模型发信。", "Only saved rules affect model-initiated sends.")),
2292
2297
  h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-to" }, dt("试填收件人 To", "Try recipients To")), h("input", { id: "dshe-rule-to", type: "text", value: to, disabled: busy, onChange: event => { setTo(event.target.value); setMatch(null); } })]),
2293
2298
  h("div", { className: "dshe-field" }, [h("label", { htmlFor: "dshe-rule-cc" }, dt("试填抄送 Cc", "Try copy Cc")), h("input", { id: "dshe-rule-cc", type: "text", value: cc, disabled: busy, onChange: event => { setCc(event.target.value); setMatch(null); } })]),
2294
2299
  h("button", { type: "button", className: "dshe-btn", disabled: busy || !account, onClick: () => run(async () => setMatch(await draftApi("matchRules", { account, policy: policy(), to, cc }))) }, dt("检查规则匹配(不发送)", "Check matches (no email sent)")),
2295
- match ? h("div", { className: "dshe-review" }, [h("p", null, match.skipsApproval ? dt("全部 To/Cc 匹配,可跳过插件确认。", "All To/Cc match; plugin approval can be skipped.") : dt("不会跳过插件确认。", "Plugin approval will remain.")), h("ul", { className: "dshe-match-list" }, match.rows.map((row, index) => h("li", { key: index }, row.field.toUpperCase() + ": " + row.address + " · " + (row.matched ? dt("匹配:", "Match: ") + row.value : dt("未匹配", "No match")))))]) : null,
2300
+ match ? h("div", { className: "dshe-review", role: "status" }, [h("p", null, match.blocked ? dt("收件人被禁止,无法发送。", "A recipient is blocked; sending is refused.") : match.skipsApproval ? dt("全部 To/Cc 匹配,可跳过插件确认。", "All To/Cc match; plugin approval can be skipped.") : dt("不会跳过插件确认。", "Plugin approval will remain.")), h("ul", { className: "dshe-match-list" }, match.rows.map((row, index) => h("li", { key: index }, row.field.toUpperCase() + ": " + row.address + " · " + (row.blocked ? dt("禁止:", "Blocked: ") + row.value : row.matched ? dt("匹配:", "Match: ") + row.value : dt("未匹配", "No match")))))]) : null,
2296
2301
  ]);
2297
2302
  }
2298
2303
 
@@ -2300,6 +2305,11 @@ function EmailSettingsSection() {
2300
2305
  const [tab, setTab] = useState(emailActiveTab);
2301
2306
  useEffect(() => { emailActiveTab = tab; }, [tab]);
2302
2307
  const [draft, setDraft] = useState(null);
2308
+ useEffect(() => {
2309
+ if (draft === null) return undefined;
2310
+ updateWhalePreference?.(draft.newMailPopup !== false);
2311
+ return () => updateWhalePreference?.(null);
2312
+ }, [draft?.newMailPopup]);
2303
2313
  const [snapshot, setSnapshot] = useState(undefined);
2304
2314
  const [busy, setBusy] = useState(false);
2305
2315
  const [error, setError] = useState("");
@@ -2592,6 +2602,16 @@ function EmailSettingsSection() {
2592
2602
  // 全局设置:发信确认和下载目录对所有账号一视同仁,不属于任何一张卡片,
2593
2603
  // 所以摆在卡片列表上方,而不是塞进某一张卡里。
2594
2604
  h("div", { className: "dshe-global" }, [
2605
+ h("label", { className: "dshe-check" }, [
2606
+ h("input", {
2607
+ type: "checkbox",
2608
+ checked: draft.newMailPopup !== false,
2609
+ disabled: snapshot?.writable === false,
2610
+ onChange: (e) => update({ newMailPopup: e.target.checked }),
2611
+ }),
2612
+ dt("显示新邮件弹窗", "Show new-mail popups"),
2613
+ ]),
2614
+ 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.")),
2595
2615
  h("label", { className: "dshe-check" }, [
2596
2616
  h("input", {
2597
2617
  type: "checkbox",
@@ -2641,6 +2661,7 @@ function EmailSettingsSection() {
2641
2661
  // when newCount > 0. The artwork URL/credit come from the settings snapshot
2642
2662
  // (skin artwork is served from the user's local dsh-deep-whale install with
2643
2663
  // its CC BY-NC-SA attribution chain; otherwise a built-in fallback is used).
2664
+ let updateWhalePreference = null;
2644
2665
  function startWhaleWidget() {
2645
2666
  if (document.querySelector("#dsh-email-whale-root")) return () => {};
2646
2667
  const root = document.createElement("div");
@@ -2651,17 +2672,26 @@ function startWhaleWidget() {
2651
2672
  let hideTimer = 0;
2652
2673
  let whaleUrl = "";
2653
2674
  let whaleCredit = "";
2675
+ let enabled = true;
2676
+ let localPreference = null;
2677
+ let disposed = false;
2678
+ let running = false;
2679
+ let generation = 0;
2680
+ let seedBaseline = true;
2681
+ let watchAbort = null;
2654
2682
  /** 当前弹窗的数据。语言切了就靠它原样重画一遍,无需再拉一次接口。 */
2655
2683
  let current = null;
2656
2684
 
2657
2685
  const closePopup = () => {
2658
2686
  if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0; }
2659
2687
  root.replaceChildren();
2688
+ current = null;
2660
2689
  };
2661
2690
 
2662
2691
  const showPopup = (value) => {
2663
- current = value;
2664
2692
  closePopup();
2693
+ if (disposed || !enabled) return;
2694
+ current = value;
2665
2695
  const card = document.createElement("div");
2666
2696
  card.className = "dshe-whale-card";
2667
2697
  if (whaleUrl) {
@@ -2709,46 +2739,63 @@ function startWhaleWidget() {
2709
2739
  };
2710
2740
 
2711
2741
  // 轮询策略:页面不可见时完全不打扰邮箱服务器;失败指数退避(上限 10 分钟),
2712
- // 成功立刻回到 30s;快照(皮肤 / 账号列表)变化很慢,最多 10 分钟刷新一次。
2742
+ // 成功立刻回到 30s;本地设置快照同样刷新,以便跨页面的通知开关生效。
2713
2743
  const POLL_MS = 30000;
2714
- const SNAPSHOT_MS = 600000;
2715
2744
  const MAX_BACKOFF_MS = 600000;
2716
2745
  let failures = 0;
2717
- let lastSnapshot = 0;
2718
- let hasAccounts = true;
2746
+ const setEnabled = (value) => {
2747
+ if (value === enabled) return;
2748
+ enabled = value;
2749
+ generation++;
2750
+ seedBaseline = true;
2751
+ watchAbort?.abort();
2752
+ if (!enabled) closePopup();
2753
+ };
2754
+ updateWhalePreference = value => {
2755
+ localPreference = value;
2756
+ if (value !== null) setEnabled(value);
2757
+ };
2719
2758
 
2720
2759
  const tick = async () => {
2721
- if (document.hidden) return;
2760
+ if (disposed || document.hidden || running) return;
2761
+ running = true;
2722
2762
  try {
2723
- if (!hasAccounts || Date.now() - lastSnapshot > SNAPSHOT_MS) {
2724
- const snap = await api();
2725
- lastSnapshot = Date.now();
2726
- if (snap && snap.whale) {
2727
- whaleUrl = (snap.whale.url || "").replace(/^\/_dsh\//, "_dsh/");
2728
- whaleCredit = snap.whale.credit || "";
2729
- }
2730
- hasAccounts = !!(snap && snap.accounts && snap.accounts.length > 0);
2763
+ // Refresh the small local snapshot even when disabled so another tab's
2764
+ // saved preference takes effect. This does not contact the mail server.
2765
+ const snap = await api();
2766
+ if (disposed) return;
2767
+ setEnabled(localPreference ?? (snap?.settings?.value?.newMailPopup !== false));
2768
+ if (snap && snap.whale) {
2769
+ whaleUrl = (snap.whale.url || "").replace(/^\/_dsh\//, "_dsh/");
2770
+ whaleCredit = snap.whale.credit || "";
2731
2771
  }
2732
- if (!hasAccounts) return;
2733
- const value = await api("watch", { limit: 5 });
2734
- if (value && value.newCount > 0) showPopup(value);
2772
+ if (!enabled || !snap?.accounts?.length || document.hidden) { failures = 0; return; }
2773
+ const version = generation;
2774
+ watchAbort = new AbortController();
2775
+ const value = await api("watch", { limit: 5 }, watchAbort.signal);
2776
+ if (disposed || !enabled || version !== generation || document.hidden) return;
2777
+ if (!seedBaseline && value && value.newCount > 0) showPopup(value);
2778
+ seedBaseline = false;
2735
2779
  failures = 0;
2736
2780
  } catch (e) {
2737
2781
  // Not configured or transient error: stay silent and back off.
2738
2782
  failures = Math.min(failures + 1, 8);
2783
+ } finally {
2784
+ running = false;
2785
+ watchAbort = null;
2739
2786
  }
2740
2787
  };
2741
2788
 
2742
2789
  const schedule = () => {
2743
2790
  clearTimeout(pollTimer);
2744
- if (document.hidden) return; // 回到前台时 onVisibility 会立刻补一次
2791
+ if (disposed || document.hidden) return;
2745
2792
  const delay = failures === 0 ? POLL_MS : Math.min(POLL_MS * Math.pow(2, failures - 1), MAX_BACKOFF_MS);
2746
2793
  pollTimer = setTimeout(() => { void tick().then(schedule); }, delay);
2747
2794
  };
2748
2795
 
2749
2796
  const onVisibility = () => {
2750
2797
  clearTimeout(pollTimer);
2751
- if (document.hidden) return;
2798
+ if (disposed || document.hidden) { watchAbort?.abort(); closePopup(); return; }
2752
2799
  failures = 0;
2753
2800
  void tick().then(schedule);
2754
2801
  };
@@ -2757,10 +2804,14 @@ function startWhaleWidget() {
2757
2804
  // 弹窗是命令式 DOM,没有 React 那层重渲染:语言切换后自己重画一次(开着的才重画)。
2758
2805
  relocalize = () => { if (current !== null && root.childElementCount > 0) showPopup(current); };
2759
2806
  return () => {
2807
+ disposed = true;
2808
+ generation++;
2809
+ watchAbort?.abort();
2760
2810
  clearTimeout(pollTimer);
2761
2811
  document.removeEventListener("visibilitychange", onVisibility);
2762
2812
  if (hideTimer) clearTimeout(hideTimer);
2763
2813
  relocalize = null;
2814
+ updateWhalePreference = null;
2764
2815
  root.remove();
2765
2816
  };
2766
2817
  }
package/lib/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type RecipientPolicy } from './recipient-policy.js';
1
2
  /** The 8 built-in provider ids. A `provider` may also name a custom preset. */
2
3
  export type ProviderName = 'qq' | '163' | '126' | 'sina' | 'aliyun' | 'gmail' | 'outlook' | 'icloud';
3
4
  /**
@@ -108,6 +109,8 @@ export interface AccountConfig {
108
109
  inboxFolder?: string;
109
110
  }
110
111
  export interface EmailConfig extends AccountConfig {
112
+ /** Show browser new-mail notifications. UI policy only; default true. */
113
+ newMailPopup?: boolean;
111
114
  /** Per-account recipient rules, independent of IMAP/SMTP connection settings. */
112
115
  trustedRecipientsYaml?: string;
113
116
  /** Ask the user for approval before email_send. Default true. */
@@ -215,6 +218,7 @@ export interface ResolvedEmailConfig {
215
218
  export declare function isOAuth2Account(provider: string | undefined, imapHost: string | undefined): boolean;
216
219
  /** Fully resolved plugin settings: the account map plus shared policy. */
217
220
  export interface ResolvedEmailSettings {
221
+ recipientPolicies?: Map<string, RecipientPolicy>;
218
222
  accounts: Map<string, ResolvedEmailConfig>;
219
223
  defaultAccount: string;
220
224
  sendApproval: boolean;
package/lib/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
3
3
  import { join } from 'node:path';
4
+ import { parseRecipientPolicies } from './recipient-policy.js';
4
5
  /**
5
6
  * The provider that authenticates with OAuth2 instead of a password.
6
7
  *
@@ -293,6 +294,7 @@ export function resolveEmailSettings(config, onlyAccount) {
293
294
  }
294
295
  return {
295
296
  accounts,
297
+ recipientPolicies: parseRecipientPolicies(raw.trustedRecipientsYaml),
296
298
  defaultAccount: defaultName,
297
299
  sendApproval: raw.sendApproval !== false,
298
300
  maxBodyChars: clampInt(raw.maxBodyChars, 20000, 1000, 200000),
package/lib/draft-web.js CHANGED
@@ -3,7 +3,7 @@ import { createReadStream } from 'node:fs';
3
3
  import { mkdir, open, stat } from 'node:fs/promises';
4
4
  import { basename, dirname, resolve } from 'node:path';
5
5
  import { DraftError, DraftStore, draftFields } from './draft-store.js';
6
- import { matchRecipients, parseRecipientPolicies, serializeRecipientPolicies } from './recipient-policy.js';
6
+ import { assertRecipientsAllowed, matchRecipients, parseRecipientPolicies, serializeRecipientPolicies, RecipientPolicyError } from './recipient-policy.js';
7
7
  import { messageOf, redactCredentials } from './mail-client.js';
8
8
  export const EMAIL_DRAFT_ROUTE = '/api/dsh-email/drafts';
9
9
  const MAX_BYTES = 20 * 1024 * 1024;
@@ -229,6 +229,7 @@ export class EmailDraftBackend {
229
229
  editable(before);
230
230
  if (before.revision !== plan.draft.revision || plan.key !== this.connection(before.account).key)
231
231
  throw new DraftError('草稿、账号或规则已改变,请重新预览', 'draft-conflict');
232
+ await assertRecipientsAllowed(before.to, before.cc, this.connection(before.account).policy);
232
233
  const pool = this.runtime.getPool();
233
234
  current.throwIfAborted();
234
235
  const pending = await this.store.change(id, before.revision, value => { editable(value); value.state = 'sending'; value.attemptAt = new Date(this.now()).toISOString(); }, current);
@@ -247,6 +248,10 @@ export class EmailDraftBackend {
247
248
  receipt = await pool.sendPrepared(before.account, before.to, before.subject, before.text, before.cc, plan.attachments.map(({ filename, content }) => ({ filename, content })), current);
248
249
  }
249
250
  catch (error) {
251
+ if (error instanceof RecipientPolicyError) {
252
+ await this.store.change(id, pending.revision, value => { value.state = 'draft'; delete value.attemptAt; });
253
+ throw error;
254
+ }
250
255
  const failed = await this.store.change(id, pending.revision, value => { value.state = 'uncertain'; value.error = '提交结果未确定;请先去邮箱核对,不会自动重发。' + this.safe(error); }).catch(() => null);
251
256
  if (!failed)
252
257
  throw new DraftError('提交结果未确定,且回执未能保存;请先去邮箱核对,不要直接重发', 'draft-storage');
@@ -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(),
@@ -4,6 +4,7 @@ import { createConnection } from 'node:net';
4
4
  import { mkdir, stat, writeFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
7
+ import { assertRecipientsAllowed } from './recipient-policy.js';
7
8
  import { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
8
9
  export class MailError extends Error {
9
10
  constructor(message) {
@@ -630,6 +631,10 @@ export class EmailPool {
630
631
  * single attempt they always had.
631
632
  */
632
633
  async sendMail(name, cfg, message, signal) {
634
+ signal?.throwIfAborted();
635
+ // This is shared by chat sends, prepared drafts, replies and forwards. Enforce
636
+ // before token refresh or opening any SMTP connection, even with approval off.
637
+ await assertRecipientsAllowed(message.to ?? '', message.cc ?? '', this.settings.recipientPolicies?.get(name));
633
638
  signal?.throwIfAborted();
634
639
  const attempt = async (forceToken) => {
635
640
  let token;
@@ -2,14 +2,21 @@ export interface RecipientPolicy {
2
2
  skipApproval: boolean;
3
3
  addresses: string[];
4
4
  domains: string[];
5
+ denyAddresses?: string[];
6
+ denyDomains?: string[];
5
7
  }
6
8
  export interface RecipientMatch {
7
9
  field: 'to' | 'cc';
8
10
  address: string;
9
11
  matched: boolean;
12
+ blocked: boolean;
10
13
  rule: 'address' | 'domain' | '';
11
14
  value: string;
12
15
  }
16
+ export declare class RecipientPolicyError extends Error {
17
+ readonly code = "recipient-denied";
18
+ constructor(addresses: string[]);
19
+ }
13
20
  export declare function canonicalDomain(value: string): string;
14
21
  export declare function canonicalAddress(value: string): string;
15
22
  /** The public Nodemailer JSON transport compiles the same recipient envelope without delivery. */
@@ -23,4 +30,6 @@ export declare function matchRecipients(to: string, cc: string, policy?: Recipie
23
30
  rows: RecipientMatch[];
24
31
  allTrusted: boolean;
25
32
  skipsApproval: boolean;
33
+ blocked: boolean;
26
34
  }>;
35
+ export declare function assertRecipientsAllowed(to: string, cc: string, policy?: RecipientPolicy): Promise<void>;
@@ -1,6 +1,10 @@
1
1
  import nodemailer from 'nodemailer';
2
2
  import { domainToASCII } from 'node:url';
3
3
  import { parse, stringify } from 'yaml';
4
+ export class RecipientPolicyError extends Error {
5
+ code = 'recipient-denied';
6
+ constructor(addresses) { super('收件人被禁止规则拦截,邮件未发送 / Recipients blocked; no email sent: ' + addresses.join(', ')); this.name = 'RecipientPolicyError'; }
7
+ }
4
8
  const normalizer = nodemailer.createTransport({ jsonTransport: true, disableFileAccess: true, disableUrlAccess: true });
5
9
  export function canonicalDomain(value) {
6
10
  const domain = domainToASCII(value.trim().replace(/\.$/, '')).toLowerCase();
@@ -57,8 +61,8 @@ export function parseRecipientPolicies(text) {
57
61
  throw new Error('规则应按账号名填写,最多 50 个账号');
58
62
  const policies = new Map();
59
63
  for (const [account, raw] of Object.entries(data)) {
60
- if (!account.trim() || account.length > 200 || !plain(raw) || Object.keys(raw).some(key => !['skipApproval', 'addresses', 'domains'].includes(key)))
61
- throw new Error('账号规则只支持 skipApproval、addresses 与 domains');
64
+ if (!account.trim() || account.length > 200 || !plain(raw) || Object.keys(raw).some(key => !['skipApproval', 'addresses', 'domains', 'denyAddresses', 'denyDomains'].includes(key)))
65
+ throw new Error('账号规则只支持 skipApproval、addresses、domains、denyAddresses 与 denyDomains');
62
66
  if (raw.skipApproval !== undefined && typeof raw.skipApproval !== 'boolean')
63
67
  throw new Error('skipApproval 必须是布尔值');
64
68
  const list = (key, convert) => {
@@ -67,7 +71,7 @@ export function parseRecipientPolicies(text) {
67
71
  throw new Error(key + ' 必须为字符串数组,最多 100 项');
68
72
  return [...new Set(values.map(value => convert(value)))];
69
73
  };
70
- policies.set(account, { skipApproval: raw.skipApproval === true, addresses: list('addresses', canonicalAddress), domains: list('domains', canonicalDomain) });
74
+ policies.set(account, { skipApproval: raw.skipApproval === true, addresses: list('addresses', canonicalAddress), domains: list('domains', canonicalDomain), denyAddresses: list('denyAddresses', canonicalAddress), denyDomains: list('denyDomains', canonicalDomain) });
71
75
  }
72
76
  return policies;
73
77
  }
@@ -76,13 +80,24 @@ export function serializeRecipientPolicies(policies) {
76
80
  }
77
81
  export async function matchRecipients(to, cc, policy) {
78
82
  const rows = (await normalizeRecipients(to, cc)).map(row => {
79
- if (policy?.addresses.includes(row.address))
80
- return { ...row, matched: true, rule: 'address', value: row.address };
81
83
  const domain = row.address.slice(row.address.lastIndexOf('@') + 1);
84
+ if (policy?.denyAddresses?.includes(row.address))
85
+ return { ...row, matched: false, blocked: true, rule: 'address', value: row.address };
86
+ if (policy?.denyDomains?.includes(domain))
87
+ return { ...row, matched: false, blocked: true, rule: 'domain', value: domain };
88
+ if (policy?.addresses.includes(row.address))
89
+ return { ...row, matched: true, blocked: false, rule: 'address', value: row.address };
82
90
  if (policy?.domains.includes(domain))
83
- return { ...row, matched: true, rule: 'domain', value: domain };
84
- return { ...row, matched: false, rule: '', value: '' };
91
+ return { ...row, matched: true, blocked: false, rule: 'domain', value: domain };
92
+ return { ...row, matched: false, blocked: false, rule: '', value: '' };
85
93
  });
86
94
  const allTrusted = rows.length > 0 && rows.every(row => row.matched);
87
- return { rows, allTrusted, skipsApproval: policy?.skipApproval === true && allTrusted };
95
+ return { rows, allTrusted, skipsApproval: policy?.skipApproval === true && allTrusted, blocked: rows.some(row => row.blocked) };
96
+ }
97
+ export async function assertRecipientsAllowed(to, cc, policy) {
98
+ if (!policy?.denyAddresses?.length && !policy?.denyDomains?.length)
99
+ return;
100
+ const matching = await matchRecipients(to, cc, policy);
101
+ if (matching.blocked)
102
+ throw new RecipientPolicyError(matching.rows.filter(row => row.blocked).map(row => row.address));
88
103
  }
package/lib/runtime.js CHANGED
@@ -71,6 +71,7 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
71
71
  });
72
72
  };
73
73
  let pool = null;
74
+ let poolSettings = null;
74
75
  let poolFingerprint = '';
75
76
  let disposed = false;
76
77
  const getPool = () => {
@@ -81,9 +82,14 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
81
82
  if (pool === null || fingerprint !== poolFingerprint) {
82
83
  pool?.dispose();
83
84
  pool = createPool(effective);
85
+ poolSettings = effective;
84
86
  pool.startIdleSweep();
85
87
  poolFingerprint = fingerprint;
86
88
  }
89
+ // Rules change without disrupting an IMAP connection or watch cursor. The
90
+ // pool reads this shared settings object at its SMTP submission boundary.
91
+ if (poolSettings)
92
+ poolSettings.recipientPolicies = effective.recipientPolicies;
87
93
  return pool;
88
94
  };
89
95
  // Tool and browser watches share the implementation but never consume each
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.2",
3
+ "version": "0.16.0",
4
4
  "description": "在 DSH 中收发、搜索和整理邮件,支持多个邮箱账号。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",