dsh-email 0.15.3 → 0.16.1
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 +9 -0
- package/README.en.md +4 -2
- package/README.md +4 -2
- package/docs/CHANGELOG.en.md +9 -0
- package/docs/DRAFTS.en.md +1 -1
- package/docs/DRAFTS.md +1 -1
- package/docs/USAGE.en.md +6 -0
- package/docs/USAGE.md +6 -0
- package/docs/VALIDATION.md +2 -0
- package/docs/validation/0.16.1.md +21 -0
- package/lib/approval.js +9 -4
- package/lib/client.js +15 -11
- 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/lib/tool-contract.js +3 -3
- package/lib/tools.js +10 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.16.1 (2026-10-08)
|
|
8
|
+
|
|
9
|
+
- 修复切换会话工作区后相对附件可能读取启动目录同名文件的问题:发信与草稿工具都按当前会话解析路径,绝对路径和无会话调用保持兼容。
|
|
10
|
+
- 中英文账号页及模型工具说明区分逐次确认、主动启用的可信规则与禁止规则,提供 Full Access 下使用草稿手动核对的入口;修正配置文档中的禁止规则字段名。
|
|
11
|
+
|
|
12
|
+
## 0.16.0 (2026-10-05)
|
|
13
|
+
|
|
14
|
+
- 增加每账号收件人禁止地址与禁止域名;禁止规则优先于可信名单,并在所有发信路径的 SMTP 连接前拦截。草稿预览显示阻止原因并保留可编辑草稿。
|
|
15
|
+
|
|
7
16
|
## 0.15.3 (2026-10-04)
|
|
8
17
|
|
|
9
18
|
- 完成 [#21](https://github.com/STARDUSTLC666/dsh-email/issues/21):邮箱账号页新增「显示新邮件弹窗」开关,自动保存、重启后保留。关闭时隐藏当前卡片并停止自动邮件检查,不影响手动邮件工具。
|
package/README.en.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
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
|
+
|
|
11
|
+
Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-email/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-email/pulls).
|
|
10
12
|
|
|
11
13
|
## What it does
|
|
12
14
|
|
|
@@ -15,7 +17,7 @@ Read, send, search and organize email from DSH with multiple accounts.
|
|
|
15
17
|
- Configure multiple accounts, Outlook OAuth2 and send approval.
|
|
16
18
|
- Turn new-mail popups off in settings; the preference survives restarts.
|
|
17
19
|
- Prepare and edit local drafts, review To/Cc, content and attachment bytes, then confirm sending.
|
|
18
|
-
- Manage trusted recipients per account and preview address, group and Cc matches.
|
|
20
|
+
- Manage trusted and blocked recipients per account and preview address, group and Cc matches.
|
|
19
21
|
|
|
20
22
|
## Install
|
|
21
23
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
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
|
+
|
|
11
|
+
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-email/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-email/pulls)。
|
|
10
12
|
|
|
11
13
|
## 功能
|
|
12
14
|
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
- 多账号设置,支持 Outlook OAuth2 和发信审批。
|
|
16
18
|
- 新邮件弹窗可在设置中关闭,保存后重启仍生效。
|
|
17
19
|
- 准备和编辑本地草稿,核对发件人、To/Cc、正文与附件预览后再确认发送。
|
|
18
|
-
-
|
|
20
|
+
- 按账号管理可信与禁止收件人,预览规则如何匹配地址、地址组和抄送。
|
|
19
21
|
|
|
20
22
|
## 安装
|
|
21
23
|
|
package/docs/CHANGELOG.en.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
|
|
6
6
|
|
|
7
|
+
## 0.16.1 (2026-10-08)
|
|
8
|
+
|
|
9
|
+
- Resolve relative attachments against the current session workspace in both send and draft tools, preserving absolute paths and calls without session context.
|
|
10
|
+
- Clarify saved trust, deny rules and Full Access confirmation behavior in both languages and model-facing descriptions; correct the documented deny-rule field names.
|
|
11
|
+
|
|
12
|
+
## 0.16.0 (2026-10-05)
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
7
16
|
## 0.14.7 (2026-10-02)
|
|
8
17
|
|
|
9
18
|
- Account cards display the effective IMAP/SMTP endpoints. Changing providers clears stale display values without persisting the endpoint summary into account configuration.
|
package/docs/DRAFTS.en.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Ask the assistant to use `email_draft`, or create a draft in Settings → Email → Drafts. The tool only saves a local draft; it does not submit mail. Parameters are `to`, `subject`, `text`, with optional `account`, `cc`, and an `attachments` array of local paths.
|
|
8
8
|
|
|
9
9
|
1. Choose the sender account, edit To/Cc, subject and plain text, then save.
|
|
10
|
-
2. Add attachments. Browser uploads become plugin-owned copies; tool-supplied paths remain references to the original files.
|
|
10
|
+
2. Add attachments. Browser uploads become plugin-owned copies; tool-supplied paths remain references to the original files. Relative tool paths resolve against the current session workspace, while absolute paths remain exact. Calls without session context use the DSH startup directory.
|
|
11
11
|
3. Review the actual sender and display name, every expanded To/Cc address, content, attachment names and sizes. Download preview attachments to inspect them.
|
|
12
12
|
4. Acknowledge the review and confirm sending. Drafts require manual confirmation even when all recipients match trusted rules.
|
|
13
13
|
|
package/docs/DRAFTS.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
让助手使用 `email_draft` 准备邮件,或在「设置 → 邮件 → 草稿」新建。这个工具只写本地草稿,不连接 SMTP 发信。参数为 `to`、`subject`、`text`,可选 `account`、`cc` 和本地附件路径数组 `attachments`。
|
|
8
8
|
|
|
9
9
|
1. 选择发件账号,编辑 To、Cc、主题和纯文本正文,然后保存。
|
|
10
|
-
2.
|
|
10
|
+
2. 添加附件。网页文件选择器保存本插件的副本;工具提供的路径保留原文件引用。工具中的相对路径按当前会话工作区解析,绝对路径保持原样;没有会话上下文时使用 DSH 启动目录。
|
|
11
11
|
3. 点「检查发送预览」,核对真实发件地址与显示名、展开后的全部 To/Cc、正文、附件名称和大小。可下载本次预览的附件检查。
|
|
12
12
|
4. 勾选已核对,再点「确认发送」。收件人规则即使全部匹配,草稿仍需这次手动确认。
|
|
13
13
|
|
package/docs/USAGE.en.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## Current improvements
|
|
6
|
+
|
|
7
|
+
Relative attachment paths in `email_send` and `email_draft` resolve against the current session workspace, avoiding a same-name file in the DSH startup directory after switching workspaces. Absolute paths remain exact; calls without session context retain startup-directory fallback.
|
|
8
|
+
|
|
9
|
+
Set blocked addresses and domains per account in the Recipient Rules tab; the text configuration fields are `denyAddresses` / `denyDomains`. Rules cover To and Cc and remain active with send approval off. Domains match exactly, excluding subdomains unless listed. Remove blocked recipients and preview again before sending. Trust skips `email_send` plugin confirmation only when every To/Cc address matches and skipping has been explicitly enabled; host permissions still apply.
|
|
10
|
+
|
|
5
11
|
## Tools
|
|
6
12
|
|
|
7
13
|
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,12 @@
|
|
|
2
2
|
|
|
3
3
|
[返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## 本次改进
|
|
6
|
+
|
|
7
|
+
`email_send` 和 `email_draft` 的相对附件路径以当前会话工作区为基准,避免切换工作区后误读 DSH 启动目录的同名文件;绝对路径保持原样,没有会话上下文时仍使用启动目录。
|
|
8
|
+
|
|
9
|
+
在「收件人规则」页按账号设置禁止地址与域名;配置文本的字段为 `denyAddresses` / `denyDomains`。禁止规则覆盖 To、Cc,即使关闭发信确认也生效。域名按完整域名匹配,不自动包含子域名。移除被禁止的收件人并重新预览后才能确认发送;只有全部 To/Cc 匹配且主动开启的可信规则才会跳过 `email_send` 插件确认,宿主权限仍生效。
|
|
10
|
+
|
|
5
11
|
## 工具一览
|
|
6
12
|
|
|
7
13
|
可编辑草稿和按账号的可信收件人规则见[草稿工作台](DRAFTS.md)。新工作台已用官方 Harness 0.2.0-rc.2 验收。
|
package/docs/VALIDATION.md
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 0.16.1:会话附件与发信策略说明
|
|
2
|
+
|
|
3
|
+
日期:2026-10-08。Windows / Node 24.16.0,官方 DSH 0.2.1-alpha.1 的隔离网页版。
|
|
4
|
+
|
|
5
|
+
## 修复与回归
|
|
6
|
+
|
|
7
|
+
- 在启动目录和会话工作区分别放置同名 `report.txt`,内容不同。`email_send` 实际文件读取和 `email_draft` 的附件预览均取得会话文件内容,启动目录原文件保持不变。
|
|
8
|
+
- 绝对附件路径保持精确;没有会话时保留启动目录回退。三个新增回归通过,既有收件人拒绝、确认、取消、大小限制与草稿快照规则保持。
|
|
9
|
+
- 中英文确认开关不再笼统声称 Full Access 必然拒绝:主动保存且启用的可信规则只跳过 `email_send` 插件确认,宿主权限仍生效;禁止规则始终优先。
|
|
10
|
+
|
|
11
|
+
## 实际界面操作
|
|
12
|
+
|
|
13
|
+
通过明确标记的脚本工具选择适配器,在聊天中用相对路径创建草稿;进入「设置 → 邮件 → 草稿」,检查发送预览,下载并核对附件,勾选核对框后向本机合成 SMTP 服务确认发送。服务器仅接收一封邮件,附件内容与当前会话文件一致;已发送草稿不再提供再次发送按钮。
|
|
14
|
+
|
|
15
|
+
切换官方界面语言后核对英文账号页,确认说明、草稿入口和禁止规则提示均显示英文。1280×720 截图中相关说明可读,浏览器控制台无 error / warn,页面无空白或错误覆盖层。
|
|
16
|
+
|
|
17
|
+
## 范围
|
|
18
|
+
|
|
19
|
+
未操作用户原生 Electron 窗口,未使用真实邮箱凭据,未向外部邮箱发送测试邮件。脚本选工具验收不证明模型自主选工具能力;UI 的 SMTP 回执不代表外部收件箱送达。附件仅改变路径基准,不增加文件访问权限或关闭确认。
|
|
20
|
+
|
|
21
|
+
开放反馈 [Email #19](https://github.com/STARDUSTLC666/dsh-email/issues/19) 的名单控件、匹配预览和显式开关均已具备;仍保留主动启用、域名精确匹配及宿主权限边界,咨询没有被自动关闭。
|
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
|
@@ -288,10 +288,10 @@ var UI = {
|
|
|
288
288
|
"provider.undefinedPresetSuffix": "(未定义预设)",
|
|
289
289
|
|
|
290
290
|
// 全局设置
|
|
291
|
-
"global.sendApproval": "
|
|
291
|
+
"global.sendApproval": "模型发信前确认(建议保留;可信规则可按账号跳过)",
|
|
292
292
|
"global.downloadDirLabel": "附件下载目录(默认保存在当前工作区)",
|
|
293
293
|
"global.downloadDirPlaceholder": "留空使用默认",
|
|
294
|
-
"global.hint": "
|
|
294
|
+
"global.hint": "以上设置对所有账号统一生效。需要插件确认的工具发信在 Full Access 下不会弹框;可改用草稿页手动核对并确认。禁止收件人规则始终生效。",
|
|
295
295
|
|
|
296
296
|
// 自动保存状态条
|
|
297
297
|
"status.saved": "已保存",
|
|
@@ -469,10 +469,10 @@ var UI = {
|
|
|
469
469
|
"provider.undefinedPresetSuffix": "(undefined preset)",
|
|
470
470
|
|
|
471
471
|
// 全局设置
|
|
472
|
-
"global.sendApproval": "
|
|
472
|
+
"global.sendApproval": "Confirm model-initiated sends (recommended; saved trust can skip per account)",
|
|
473
473
|
"global.downloadDirLabel": "Attachment download directory (defaults to the current workspace)",
|
|
474
474
|
"global.downloadDirPlaceholder": "Leave empty for the default",
|
|
475
|
-
"global.hint": "These settings apply to every account.",
|
|
475
|
+
"global.hint": "These settings apply to every account. Tool sends that still require plugin confirmation cannot show a dialog under Full Access; use the Drafts tab to review and confirm manually. Blocked-recipient rules always apply.",
|
|
476
476
|
|
|
477
477
|
// 自动保存状态条
|
|
478
478
|
"status.saved": "Saved",
|
|
@@ -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
|
package/lib/tool-contract.js
CHANGED
|
@@ -297,8 +297,8 @@ export const descriptions = {
|
|
|
297
297
|
"email_read": 'Read one full email message by its uid (from email_list or email_search). Returns the plain-text body (HTML mail is converted; oversized bodies are truncated) plus attachment metadata; use email_attachment to download one.',
|
|
298
298
|
"email_mark": 'Change an existing message: mark it read/unread, star/unstar it, or move it to another folder. Use after email_list/email_search when the user wants to tidy the mailbox (archive, clear unread, flag important mail). Moving uses the server MOVE/COPY so the uid changes; the new uid is reported when the server provides it.',
|
|
299
299
|
"email_search": 'Search emails by a keyword. The server first searches sender, recipients and subject; those hits are re-verified against the envelopes and, when none of them really carries the keyword (some servers answer every search with the same uids), recent messages are scanned locally including their body while bodySearchFallback is enabled. offset skips the newest matches for paging; since/until still constrain both paths. Returns the same compact rows as email_list.',
|
|
300
|
-
"email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the
|
|
301
|
-
"email_reply": 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter.
|
|
300
|
+
"email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the current session workspace; process cwd is the fallback without a session). Saved blocked-address/domain rules reject To/Cc recipients even when sendApproval is off. Plugin confirmation is required by default, unless sendApproval is disabled or every To/Cc recipient matches saved trust rules with skipApproval enabled. Host permissions still apply. Full Access refuses a send that still requires plugin confirmation; use email_draft for human review and explicit sending. Never invent recipients or content without the user\'s instruction.',
|
|
301
|
+
"email_reply": 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter. Plugin confirmation is required unless sendApproval is disabled; trusted-recipient skipping only applies to email_send. Blocked recipients still stop delivery before SMTP and host permissions remain in effect. Never invent recipients or content without the user\'s instruction.',
|
|
302
302
|
"email_folders": 'List the mailbox folders of an account (INBOX, Sent, Trash, custom folders, ...). Use the returned path values as the folder argument of the other email tools.',
|
|
303
303
|
"email_health": 'Self-check for dsh-email: summarizes configured accounts (provider / IMAP / SMTP hosts) without any network connection and never shows passwords. Run this first when troubleshooting.',
|
|
304
304
|
"email_attachment": 'Download one attachment of a message to a local file (size capped by maxAttachmentBytes). The index matches the attachments array of email_read. Returns the absolute path of the written file.',
|
|
@@ -340,7 +340,7 @@ export const parameters = {
|
|
|
340
340
|
subject: { type: 'string', required: true, description: 'Email subject' },
|
|
341
341
|
text: { type: 'string', description: 'Plain-text body' },
|
|
342
342
|
cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
|
|
343
|
-
attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the
|
|
343
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the current session workspace; process cwd is the fallback without a session)' },
|
|
344
344
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
345
345
|
}),
|
|
346
346
|
"email_reply": compileParameters({
|
package/lib/tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
1
2
|
import { clampInt, PROVIDER_PRESETS } from './config.js';
|
|
2
3
|
import { messageOf } from './mail-client.js';
|
|
3
4
|
import { NOT_LOGGED_IN_MESSAGE, oauth2StateOf } from './oauth2.js';
|
|
@@ -5,16 +6,22 @@ import { descriptions, parameters, MAX_LIMIT, MARK_ACTIONS, REPLY_MODES, executi
|
|
|
5
6
|
/** 单次工具调用的 deadline:普通查询 60s,正文扫描/watch 这类整批操作 120s。 */
|
|
6
7
|
const QUERY_TIMEOUT_MS = 60000;
|
|
7
8
|
const BATCH_TIMEOUT_MS = 120000;
|
|
9
|
+
function attachmentPaths(value, exec) {
|
|
10
|
+
const context = exec;
|
|
11
|
+
const cwd = context?.agent?.session?.header?.cwd;
|
|
12
|
+
const base = typeof cwd === 'string' && cwd.trim() ? cwd : process.cwd();
|
|
13
|
+
return normalizeAttachmentPaths(value)?.map(path => resolve(base, path));
|
|
14
|
+
}
|
|
8
15
|
export function buildEmailTools(runtime, drafts) {
|
|
9
16
|
const { getPool, getEffectiveSettings, watch: watchCore } = runtime;
|
|
10
17
|
return [
|
|
11
18
|
...(drafts ? [{
|
|
12
19
|
name: 'email_draft',
|
|
13
20
|
description: '准备可编辑的本地邮件草稿,不投递邮件。用户在设置 → 邮件 → 草稿中编辑、检查附件预览并明确确认后才发送。可在邮箱尚未配置时准备文字草稿;不用于回复原邮件线程。',
|
|
14
|
-
parameters: { type: 'object', properties: { account: { type: 'string', description: '发件账号名,省略时使用默认账号' }, to: { type: 'string', description: 'To 地址,支持显示名和地址组' }, cc: { type: 'string' }, subject: { type: 'string' }, text: { type: 'string', description: '纯文本正文' }, attachments: { type: 'array', items: { type: 'string' }, description: '最多 10
|
|
21
|
+
parameters: { type: 'object', properties: { account: { type: 'string', description: '发件账号名,省略时使用默认账号' }, to: { type: 'string', description: 'To 地址,支持显示名和地址组' }, cc: { type: 'string' }, subject: { type: 'string' }, text: { type: 'string', description: '纯文本正文' }, attachments: { type: 'array', items: { type: 'string' }, description: '最多 10 个本地附件路径;相对路径以当前会话工作区为基准,用户在预览中检查后才提交' } }, required: ['to', 'subject', 'text'], additionalProperties: false },
|
|
15
22
|
output: { schema: { type: 'object', properties: { id: { type: 'string' }, state: { type: 'string' }, subject: { type: 'string' } }, required: ['id', 'state', 'subject'] }, render: (_args, value) => [{ type: 'text', text: '本地草稿已保存,邮件未发送。请在设置 → 邮件 → 草稿中打开并检查。\n草稿编号:' + value.id + '\n主题:' + value.subject }] },
|
|
16
23
|
timeoutMs: QUERY_TIMEOUT_MS,
|
|
17
|
-
async execute(args, exec) { const draft = await drafts.create(
|
|
24
|
+
async execute(args, exec) { const input = args; const draft = await drafts.create({ ...input, attachments: attachmentPaths(input.attachments, exec) }, executionSignal(exec)); return { id: draft.id, state: draft.state, subject: draft.subject }; },
|
|
18
25
|
}] : []),
|
|
19
26
|
{
|
|
20
27
|
name: 'email_list',
|
|
@@ -110,7 +117,7 @@ export function buildEmailTools(runtime, drafts) {
|
|
|
110
117
|
throw new Error('to 不能为空');
|
|
111
118
|
if (typeof args.subject !== 'string' || args.subject.trim() === '')
|
|
112
119
|
throw new Error('subject 不能为空');
|
|
113
|
-
return await getPool().send(args.account, args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined,
|
|
120
|
+
return await getPool().send(args.account, args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined, attachmentPaths(args.attachments, exec), executionSignal(exec));
|
|
114
121
|
}
|
|
115
122
|
},
|
|
116
123
|
{
|