dsh-email 0.15.3 → 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 +4 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.en.md +4 -0
- package/docs/USAGE.en.md +4 -0
- package/docs/USAGE.md +4 -0
- package/lib/approval.js +9 -4
- package/lib/client.js +11 -7
- package/lib/config.d.ts +2 -0
- package/lib/config.js +2 -0
- package/lib/draft-web.js +6 -1
- package/lib/mail-client.js +5 -0
- package/lib/recipient-policy.d.ts +9 -0
- package/lib/recipient-policy.js +23 -8
- package/lib/runtime.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.16.0 (2026-10-05)
|
|
8
|
+
|
|
9
|
+
- 增加每账号收件人禁止地址与禁止域名;禁止规则优先于可信名单,并在所有发信路径的 SMTP 连接前拦截。草稿预览显示阻止原因并保留可编辑草稿。
|
|
10
|
+
|
|
7
11
|
## 0.15.3 (2026-10-04)
|
|
8
12
|
|
|
9
13
|
- 完成 [#21](https://github.com/STARDUSTLC666/dsh-email/issues/21):邮箱账号页新增「显示新邮件弹窗」开关,自动保存、重启后保留。关闭时隐藏当前卡片并停止自动邮件检查,不影响手动邮件工具。
|
package/README.en.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Read, send, search and organize email from DSH with multiple accounts.
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/dsh-email) [](https://www.npmjs.com/package/dsh-email) [](https://www.npmjs.com/package/dsh-email)
|
|
10
10
|
|
|
11
11
|
## What it does
|
|
12
12
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
在 DSH 中收发、搜索和整理邮件,支持多个邮箱账号。
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/dsh-email) [](https://www.npmjs.com/package/dsh-email) [](https://www.npmjs.com/package/dsh-email)
|
|
10
10
|
|
|
11
11
|
## 功能
|
|
12
12
|
|
package/docs/CHANGELOG.en.md
CHANGED
|
@@ -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.
|
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 验收。
|
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
|
|
22
|
-
|
|
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
|
@@ -2209,7 +2209,8 @@ function DraftWorkbench({ active }) {
|
|
|
2209
2209
|
h("h3", null, dt("发送前预览", "Review before sending")),
|
|
2210
2210
|
h("p", null, dt("发件人:", "From: ") + (preview.from.name ? preview.from.name + " <" + preview.from.address + ">" : preview.from.address)),
|
|
2211
2211
|
h("p", null, dt("主题:", "Subject: ") + preview.draft.subject),
|
|
2212
|
-
h("
|
|
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"))))),
|
|
2213
2214
|
h("pre", null, preview.draft.text || dt("(空正文)", "(Empty body)")),
|
|
2214
2215
|
preview.attachments.length ? h("ul", { className: "dshe-files" }, preview.attachments.map(file => h("li", { key: file.id }, [
|
|
2215
2216
|
h("span", null, file.filename + " · " + Math.ceil(file.size / 1024) + " KiB"),
|
|
@@ -2219,7 +2220,7 @@ function DraftWorkbench({ active }) {
|
|
|
2219
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")]),
|
|
2220
2221
|
h("div", { className: "dshe-actions" }, [
|
|
2221
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")),
|
|
2222
|
-
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 () => {
|
|
2223
2224
|
try { const result = await draftApi("send", { id: selected.id, preview: preview.id, confirmed: true }); adopt(result.draft); }
|
|
2224
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); }
|
|
2225
2226
|
}) }, busy ? dt("正在提交…", "Submitting…") : dt("确认发送", "Confirm send")),
|
|
@@ -2265,35 +2266,38 @@ function DraftWorkbench({ active }) {
|
|
|
2265
2266
|
}
|
|
2266
2267
|
|
|
2267
2268
|
function RecipientRules({ active, onSaved }) {
|
|
2268
|
-
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 });
|
|
2269
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);
|
|
2270
2271
|
const operation = useRef(false), dirty = data && JSON.stringify(form) !== base;
|
|
2271
2272
|
useEffect(() => { rulesUiCache = { data, account, form, base, to, cc }; }, [data, account, form, base, to, cc]);
|
|
2272
|
-
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); };
|
|
2273
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); } };
|
|
2274
2275
|
const load = async () => { const next = await draftApi("rules"); setData(next); choose(next, next.accounts.includes(account) ? account : next.accounts[0] || ""); };
|
|
2275
2276
|
useEffect(() => { if (active && !dirty) run(load); }, [active]);
|
|
2276
2277
|
useEffect(() => { const guard = event => { if (dirty) { event.preventDefault(); event.returnValue = ""; } }; window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard); }, [dirty]);
|
|
2277
|
-
const policy = () => ({ skipApproval: form.skipApproval, addresses
|
|
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)])) });
|
|
2278
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; };
|
|
2279
2280
|
const change = patch => { setForm(current => ({ ...current, ...patch })); setMatch(null); setNotice(""); };
|
|
2280
2281
|
const request = target => { if (dirty) setPending(target); else if (target.kind === "reload") run(load); else choose(data, target.name); };
|
|
2281
2282
|
return h("section", { className: "dshe-panel dshe-rule-panel", "aria-label": dt("可信收件人规则", "Trusted recipients") }, [
|
|
2282
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.")),
|
|
2283
2285
|
error ? h("div", { className: "dshe-alert error", role: "alert" }, error) : null,
|
|
2284
2286
|
notice ? h("div", { className: "dshe-alert success", role: "status" }, notice) : null,
|
|
2285
|
-
data && data.sendApproval === false ? h("div", { className: "dshe-alert info" }, dt("
|
|
2287
|
+
data && data.sendApproval === false ? h("div", { className: "dshe-alert info" }, dt("全局发信确认目前已关闭;禁止规则仍然生效。", "Global send approval is off; blocked-recipient rules still apply.")) : null,
|
|
2286
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))])]),
|
|
2287
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,
|
|
2288
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 }) })]),
|
|
2289
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 }) })]),
|
|
2290
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")]),
|
|
2291
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"))]),
|
|
2292
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.")),
|
|
2293
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); } })]),
|
|
2294
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); } })]),
|
|
2295
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)")),
|
|
2296
|
-
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,
|
|
2297
2301
|
]);
|
|
2298
2302
|
}
|
|
2299
2303
|
|
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
|
/**
|
|
@@ -217,6 +218,7 @@ export interface ResolvedEmailConfig {
|
|
|
217
218
|
export declare function isOAuth2Account(provider: string | undefined, imapHost: string | undefined): boolean;
|
|
218
219
|
/** Fully resolved plugin settings: the account map plus shared policy. */
|
|
219
220
|
export interface ResolvedEmailSettings {
|
|
221
|
+
recipientPolicies?: Map<string, RecipientPolicy>;
|
|
220
222
|
accounts: Map<string, ResolvedEmailConfig>;
|
|
221
223
|
defaultAccount: string;
|
|
222
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');
|
package/lib/mail-client.js
CHANGED
|
@@ -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>;
|
package/lib/recipient-policy.js
CHANGED
|
@@ -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 与
|
|
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
|