dsh-email 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # dsh-email
2
2
 
3
- DeepSeek Harness 邮件工具插件:让 agent 能**查收件箱、读邮件、搜邮件、代发邮件**。纯插件实现,零核心改动,安装即可用。
3
+ DeepSeek Harness 邮件工具插件:让 agent 能**查收件箱、读邮件、搜邮件、代发邮件、收发附件**。纯插件实现,零核心改动,安装即可用。
4
4
 
5
- Email tools for DeepSeek Harness: list, read, search and send mail through standard IMAP/SMTP — with one-line presets for QQ / 163 / 126 / Sina / Aliyun / Gmail / Outlook / iCloud.
5
+ Email tools for DeepSeek Harness: list, read, search and send mail through standard IMAP/SMTP — with one-line presets for QQ / 163 / 126 / Sina / Aliyun / Gmail / Outlook / iCloud.
6
+
7
+ 纯 Node 实现,**全平台通用**(Windows / macOS / Linux 同一份代码),不依赖 shell、无原生二进制。
6
8
 
7
9
  ## 工具一览
8
10
 
@@ -11,7 +13,9 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
11
13
  | `email_list` | 列出文件夹里最新的邮件(未读过滤、分页、只看摘要不带正文) |
12
14
  | `email_read` | 按 uid 读取一封邮件的全文(HTML 邮件自动转纯文本,超长截断) |
13
15
  | `email_search` | 按关键词搜索发件人/收件人/主题(服务器端 IMAP SEARCH,不搜正文) |
14
- | `email_send` | 代发邮件。**默认发信前会弹确认**,显示收件人和主题,由你批准后才发出 |
16
+ | `email_send` | 代发邮件(支持带附件)。**默认发信前会弹确认**,显示收件人、主题和附件数,由你批准后才发出 |
17
+ | `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
18
+ | `email_attachment` | 按序号下载邮件附件到本地文件(大小受 maxAttachmentBytes 限制) |
15
19
 
16
20
  示例对话:
17
21
 
@@ -25,7 +29,14 @@ dsh plugin --profile web add dsh-email
25
29
 
26
30
  (或从 GitHub 安装:`dsh plugin --profile web add github:你的账号/dsh-email#<commit>`,随后按提示在 profile 的 `pnpm-workspace.yaml` 里授权 `prepare` 构建。)
27
31
 
28
- 装好后重启 `dsh web`。插件自带空配置,**不会弄崩启动**;配置前调用任何 email 工具都会返回明确的配置提示。
32
+ 装好后重启 `dsh web`。插件自带空配置,**不会弄崩启动**;配置前调用任何 email 工具都会返回明确的配置提示。
33
+
34
+ **配置方式有两种(任选其一):**
35
+
36
+ 1. **网页设置(推荐)**:重启后打开 **设置 → 邮件 (dsh-email)**,表单里填邮箱地址和授权码,点「保存并应用」,还带「测试连接」按钮。零 YAML、零重启。
37
+ 2. **YAML**:按下面的 cordis.patch.yml 模板手写(多账号 accounts 映射目前只支持这种方式)。
38
+
39
+ 设置页保存的值存在 `settings.yaml` 的 `dsh-email` 命名空间里,覆盖 YAML 的默认账号配置;密码字段标记为 secret(不会出现在任何导出/诊断里)。
29
40
 
30
41
  ## 配置
31
42
 
@@ -51,6 +62,20 @@ dsh plugin --profile web add dsh-email
51
62
  inboxFolder: INBOX
52
63
  ```
53
64
 
65
+ 多账号:一个 `tool-email` 行可以配多个邮箱,工具调用时用 `account` 参数选择:
66
+
67
+ ```yaml
68
+ - id: tool-email
69
+ config:
70
+ accounts:
71
+ work: { provider: qq, user: work@qq.com, password: 授权码1 }
72
+ home: { provider: '163', user: home@163.com, password: 授权码2 }
73
+ defaultAccount: work # 省略 account 参数时用这个
74
+ downloadDir: E:/attachments # 可选,默认 $DSH_HOME/email-downloads
75
+ ```
76
+
77
+ 顶层的 `provider`/`user`/`password`/`imap`/`smtp`/`inboxFolder` 仍然可用,作为各账号的共享默认值(v0.1 单账号写法完全兼容)。
78
+
54
79
  ### 常用邮箱预设
55
80
 
56
81
  | provider | IMAP | SMTP |
@@ -75,7 +100,12 @@ dsh plugin --profile web add dsh-email
75
100
  | `smtp.host/port/secure` | 按预设 | 发信服务器 |
76
101
  | `inboxFolder` | `INBOX` | 收发工具默认使用的文件夹 |
77
102
  | `sendApproval` | `true` | 发信前弹确认(强烈建议保留) |
78
- | `maxBodyChars` | `20000` | email_read 正文截断上限(1000–200000) |
103
+ | `maxBodyChars` | `20000` | email_read 正文截断上限(1000–200000) |
104
+ | `accounts` | 无 | 具名账号表;账号级字段覆盖顶层简写 |
105
+ | `defaultAccount` | 单账号时自动 | 工具省略 account 参数时使用的账号(多账号必填) |
106
+ | `downloadDir` | `$DSH_HOME/email-downloads` | email_attachment 的落盘目录 |
107
+ | `maxAttachmentBytes` | 20 MiB | 单个附件与附件总大小上限(1024–512 MiB) |
108
+ | `idleTimeoutMs` | `60000` | IMAP 空闲连接回收时间(连接复用,连续操作更快) |
79
109
 
80
110
  ## 第一步:拿到授权码
81
111
 
@@ -93,20 +123,20 @@ dsh plugin --profile web add dsh-email
93
123
  - 注意:会话处于 **Full Access(完全访问)** 模式时,harness 会把审批策略置为 never,`email_send` 会被**静默拒绝**(不弹框)。想发信请把访问模式切回 Read Only 或 Write。
94
124
  - 本插件不做任何联网上报,凭证只在内存中用于连接你的邮箱服务器。
95
125
 
96
- ## 已知限制(v0.1
126
+ ## 已知限制(v0.2
97
127
 
98
- - **每个工具调用独立建立/关闭连接**:正确、无状态,但连续读多封会比常驻连接慢一点。
99
- - **单账号**:一个 `tool-email` 行对应一个邮箱;多账号可复制多行(改 id 即可)。
100
- - **附件只给元数据**(文件名/类型/大小),不下载内容;下载附件列入后续版本。
128
+ - **连接复用**:IMAP 按账号池化(空闲自动回收),SMTP 用 nodemailer 连接池;同一账号的并发调用会排队串行(一个连接一次只服务一个操作,这是有意的)。
129
+ - **多账号**:每个账号独立连接池;一个 `tool-email` 行可以配任意多个账号。设置页编辑的是默认账号;`accounts` 映射仍需写 cordis.patch.yml。
130
+ - **附件下载**:email_attachment 按序号下载(与 email_read 的 attachments 顺序一致);文件名会被清洗防路径穿越,已有同名文件自动加后缀,大小受 maxAttachmentBytes 限制。
101
131
  - **不支持 OAuth2**:强制 OAuth 的企业环境(部分 M365/Google Workspace)暂不可用。
102
- - 正文搜索不提供:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索要么全量匹配要么不支持,所以 v0.1 只搜主题/发件人/收件人;正文搜索列入后续版本(需客户端下载解析,较慢)。
132
+ - 正文搜索不提供:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索要么全量匹配要么不支持,所以只搜主题/发件人/收件人;正文搜索列入后续版本(需客户端下载解析,较慢)。
103
133
 
104
134
  ## 开发
105
135
 
106
136
  ```sh
107
137
  pnpm install
108
138
  pnpm run build # tsc → lib/
109
- pnpm test # 构建 + node --test(配置/解析/注册与审批门,19 个用例,无需真实邮箱)
139
+ pnpm test # 构建 + node --test(配置/解析/注册与审批门,31 个用例,无需真实邮箱)
110
140
  ```
111
141
 
112
142
  ## 协议
package/lib/client.js ADDED
@@ -0,0 +1,295 @@
1
+ window.__ModuleLoader__.load({ id: "dsh-email/client", factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+ "use strict";
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+
6
+ const React = require("react");
7
+ const { useState, useEffect, useCallback } = React;
8
+ const h = React.createElement;
9
+
10
+ const ROUTE = "/_dsh/dsh-email/settings";
11
+
12
+ async function api(action, payload) {
13
+ const init = action === undefined
14
+ ? { credentials: "same-origin" }
15
+ : {
16
+ credentials: "same-origin",
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify(Object.assign({ action }, payload)),
20
+ };
21
+ const res = await fetch(ROUTE, init);
22
+ const body = await res.json();
23
+ if (!res.ok || !body.ok) {
24
+ throw new Error((body && body.error && body.error.message) || ("request failed " + res.status));
25
+ }
26
+ return body.value;
27
+ }
28
+
29
+ const PROVIDERS = [
30
+ ["qq", "QQ 邮箱"],
31
+ ["163", "163 邮箱"],
32
+ ["126", "126 邮箱"],
33
+ ["sina", "新浪邮箱"],
34
+ ["aliyun", "阿里邮箱"],
35
+ ["gmail", "Gmail"],
36
+ ["outlook", "Outlook"],
37
+ ["icloud", "iCloud"],
38
+ ["", "自定义(手填 IMAP/SMTP)"],
39
+ ];
40
+
41
+ const EMPTY = {
42
+ provider: "",
43
+ user: "",
44
+ password: "",
45
+ inboxFolder: "INBOX",
46
+ sendApproval: true,
47
+ downloadDir: "",
48
+ imap: { host: "", port: 993, secure: true },
49
+ smtp: { host: "", port: 465, secure: true },
50
+ };
51
+
52
+ const CSS = [
53
+ ".dshe-settings{display:grid;gap:14px;max-width:900px;padding:8px 2px 32px;color:var(--dsw-alias-fg-primary,#26231f)}",
54
+ ".dshe-header{display:grid;gap:4px;padding:8px 2px}",
55
+ ".dshe-header h2{font-size:22px;letter-spacing:-.02em;margin:0}",
56
+ ".dshe-header p{max-width:640px;margin:4px 0 0;color:var(--dsw-alias-fg-muted,#77736d);font-size:13px;line-height:1.55}",
57
+ ".dshe-kicker{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:#0b6c9f;font-weight:700}",
58
+ ".dshe-panel{display:grid;gap:12px;padding:15px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 1px rgba(0,0,0,.02)}",
59
+ ".dshe-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}",
60
+ ".dshe-field{display:grid;gap:6px}",
61
+ ".dshe-field label{font-size:12px;font-weight:600;color:var(--dsw-alias-fg-primary,#26231f)}",
62
+ ".dshe-field input[type=text],.dshe-field input[type=password],.dshe-field input[type=number],.dshe-field select{width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#fff);color:inherit;font:inherit;font-size:13px}",
63
+ ".dshe-check{display:flex;gap:8px;align-items:center;font-size:13px}",
64
+ ".dshe-actions{display:flex;gap:8px;flex-wrap:wrap}",
65
+ ".dshe-btn{display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);background:var(--dsw-alias-bg-layer-1,#fff);color:inherit;font-size:13px;font-weight:600;cursor:pointer}",
66
+ ".dshe-btn.primary{background:#0b6c9f;border-color:#0b6c9f;color:#fff}",
67
+ ".dshe-btn:disabled{opacity:.55;cursor:default}",
68
+ ".dshe-alert{padding:10px 12px;border-radius:10px;font-size:12px;line-height:1.5}",
69
+ ".dshe-alert.error{background:rgba(205,72,72,.1);color:#aa3939}",
70
+ ".dshe-alert.success{background:rgba(48,154,100,.1);color:#267d52}",
71
+ ".dshe-alert.info{background:rgba(11,108,159,.08);color:#0b5c86}",
72
+ ".dshe-details summary{font-size:12px;font-weight:600;cursor:pointer;color:var(--dsw-alias-fg-muted,#77736d)}",
73
+ ".dshe-hint{font-size:12px;color:var(--dsw-alias-fg-muted,#77736d);line-height:1.5}",
74
+ ].join("\n");
75
+
76
+ function fieldInput(type, value, onChange, placeholder) {
77
+ return h("input", {
78
+ type,
79
+ value: value === undefined || value === null ? "" : String(value),
80
+ placeholder,
81
+ onChange: (e) => onChange(e.target.value),
82
+ });
83
+ }
84
+
85
+ function EmailSettingsSection() {
86
+ const [draft, setDraft] = useState(null);
87
+ const [snapshot, setSnapshot] = useState(undefined);
88
+ const [busy, setBusy] = useState(false);
89
+ const [testing, setTesting] = useState(false);
90
+ const [testResult, setTestResult] = useState(undefined);
91
+ const [error, setError] = useState("");
92
+ const [message, setMessage] = useState("");
93
+
94
+ const load = useCallback(async () => {
95
+ setBusy(true); setError("");
96
+ try {
97
+ const snap = await api();
98
+ setSnapshot(snap);
99
+ const value = (snap && snap.settings && snap.settings.value) || EMPTY;
100
+ setDraft({
101
+ ...EMPTY,
102
+ ...value,
103
+ imap: { ...EMPTY.imap, ...(value.imap || {}) },
104
+ smtp: { ...EMPTY.smtp, ...(value.smtp || {}) },
105
+ });
106
+ } catch (e) {
107
+ setError(e && e.message ? e.message : String(e));
108
+ } finally {
109
+ setBusy(false);
110
+ }
111
+ }, []);
112
+
113
+ useEffect(() => { load(); }, [load]);
114
+
115
+ const update = (patch) => setDraft((cur) => Object.assign({}, cur, patch));
116
+ const updateImap = (patch) => setDraft((cur) => Object.assign({}, cur, { imap: Object.assign({}, cur.imap, patch) }));
117
+ const updateSmtp = (patch) => setDraft((cur) => Object.assign({}, cur, { smtp: Object.assign({}, cur.smtp, patch) }));
118
+
119
+ const doSave = async () => {
120
+ setBusy(true); setError(""); setMessage(""); setTestResult(undefined);
121
+ try {
122
+ const rev = snapshot && snapshot.settings ? snapshot.settings.revision : 0;
123
+ const value = {
124
+ ...draft,
125
+ maxBodyChars: typeof draft.maxBodyChars === "number" ? draft.maxBodyChars : 20000,
126
+ imap: { ...draft.imap, port: Number(draft.imap.port) || 993 },
127
+ smtp: { ...draft.smtp, port: Number(draft.smtp.port) || 465 },
128
+ };
129
+ const snap = await api("save", { value, expectedRevision: rev });
130
+ setSnapshot(snap);
131
+ setMessage(draft.user && draft.user.trim()
132
+ ? "已保存并生效(下次请求即用新账号)。"
133
+ : "已保存。注意:尚未填写邮箱地址,email_* 工具调用时会提示配置。");
134
+ } catch (e) {
135
+ setError(e && e.message ? e.message : String(e));
136
+ } finally {
137
+ setBusy(false);
138
+ }
139
+ };
140
+
141
+ const doTest = async () => {
142
+ setTesting(true); setError(""); setMessage(""); setTestResult(undefined);
143
+ try {
144
+ const value = {
145
+ ...draft,
146
+ maxBodyChars: typeof draft.maxBodyChars === "number" ? draft.maxBodyChars : 20000,
147
+ imap: { ...draft.imap, port: Number(draft.imap.port) || 993 },
148
+ smtp: { ...draft.smtp, port: Number(draft.smtp.port) || 465 },
149
+ };
150
+ const result = await api("test", { value });
151
+ setTestResult({ ok: true, text: "连接成功,IMAP 登录通过(" + result.ms + " ms)。" });
152
+ } catch (e) {
153
+ setTestResult({ ok: false, text: "连接失败:" + (e && e.message ? e.message : String(e)) });
154
+ } finally {
155
+ setTesting(false);
156
+ }
157
+ };
158
+
159
+ if (draft === null) {
160
+ return h("div", { className: "dshe-settings" }, [
161
+ h("div", { className: "dshe-alert info" }, busy ? "加载中…" : (error || "加载中…")),
162
+ ]);
163
+ }
164
+
165
+ const accounts = snapshot && snapshot.accounts ? snapshot.accounts : [];
166
+
167
+ return h("div", { className: "dshe-settings" }, [
168
+ h("header", { className: "dshe-header" }, [
169
+ h("span", { className: "dshe-kicker" }, "dsh-email · IMAP/SMTP"),
170
+ h("h2", null, "邮件"),
171
+ h("p", null, "在这里填写邮箱账号,六个 email_* 工具立即生效,无需手写 YAML。密码字段标记为 secret,不会出现在任何导出里。"),
172
+ ]),
173
+ h("section", { className: "dshe-panel" }, [
174
+ h("div", { className: "dshe-grid" }, [
175
+ h("div", { className: "dshe-field" }, [
176
+ h("label", null, "邮箱服务商"),
177
+ h("select", { value: draft.provider, onChange: (e) => update({ provider: e.target.value }) },
178
+ PROVIDERS.map(([value, label]) => h("option", { key: value, value }, label))),
179
+ ]),
180
+ h("div", { className: "dshe-field" }, [
181
+ h("label", null, "邮箱地址"),
182
+ fieldInput("text", draft.user, (v) => update({ user: v }), "you@example.com"),
183
+ ]),
184
+ ]),
185
+ h("div", { className: "dshe-grid" }, [
186
+ h("div", { className: "dshe-field" }, [
187
+ h("label", null, "授权码 / 应用专用密码"),
188
+ h("input", {
189
+ type: "password",
190
+ value: draft.password,
191
+ placeholder: "不是登录密码;QQ/163 在邮箱设置里生成授权码",
192
+ onChange: (e) => update({ password: e.target.value }),
193
+ }),
194
+ ]),
195
+ h("div", { className: "dshe-field" }, [
196
+ h("label", null, "收件文件夹(默认 INBOX)"),
197
+ fieldInput("text", draft.inboxFolder, (v) => update({ inboxFolder: v }), "INBOX"),
198
+ ]),
199
+ ]),
200
+ h("label", { className: "dshe-check" }, [
201
+ h("input", {
202
+ type: "checkbox",
203
+ checked: draft.sendApproval === true,
204
+ onChange: (e) => update({ sendApproval: e.target.checked }),
205
+ }),
206
+ "发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
207
+ ]),
208
+ h("div", { className: "dshe-field" }, [
209
+ h("label", null, "附件下载目录(默认 $DSH_HOME/email-downloads)"),
210
+ fieldInput("text", draft.downloadDir, (v) => update({ downloadDir: v }), "留空使用默认"),
211
+ ]),
212
+ h("details", { className: "dshe-details" }, [
213
+ h("summary", null, "高级:自定义服务器(选了预设可留空)"),
214
+ h("div", { className: "dshe-grid", style: { marginTop: 10 } }, [
215
+ h("div", { className: "dshe-field" }, [
216
+ h("label", null, "IMAP 主机"),
217
+ fieldInput("text", draft.imap.host, (v) => updateImap({ host: v }), "imap.qq.com"),
218
+ ]),
219
+ h("div", { className: "dshe-field" }, [
220
+ h("label", null, "IMAP 端口"),
221
+ fieldInput("number", draft.imap.port, (v) => updateImap({ port: Number(v) }), "993"),
222
+ ]),
223
+ h("label", { className: "dshe-check" }, [
224
+ h("input", {
225
+ type: "checkbox",
226
+ checked: draft.imap.secure === true,
227
+ onChange: (e) => updateImap({ secure: e.target.checked }),
228
+ }),
229
+ "IMAP SSL",
230
+ ]),
231
+ h("div", { className: "dshe-field" }, [
232
+ h("label", null, "SMTP 主机"),
233
+ fieldInput("text", draft.smtp.host, (v) => updateSmtp({ host: v }), "smtp.qq.com"),
234
+ ]),
235
+ h("div", { className: "dshe-field" }, [
236
+ h("label", null, "SMTP 端口"),
237
+ fieldInput("number", draft.smtp.port, (v) => updateSmtp({ port: Number(v) }), "465"),
238
+ ]),
239
+ h("label", { className: "dshe-check" }, [
240
+ h("input", {
241
+ type: "checkbox",
242
+ checked: draft.smtp.secure === true,
243
+ onChange: (e) => updateSmtp({ secure: e.target.checked }),
244
+ }),
245
+ "SMTP SSL(Outlook/iCloud 587 端口请取消勾选)",
246
+ ]),
247
+ ]),
248
+ ]),
249
+ h("div", { className: "dshe-actions" }, [
250
+ h("button", { className: "dshe-btn primary", disabled: busy || testing, onClick: doSave }, busy ? "处理中…" : "保存并应用"),
251
+ h("button", { className: "dshe-btn", disabled: busy || testing, onClick: doTest }, testing ? "测试中…" : "测试连接"),
252
+ ]),
253
+ snapshot && snapshot.writable === false
254
+ ? h("div", { className: "dshe-alert info" }, "当前 settings 存储是只读的,只能查看不能保存。")
255
+ : null,
256
+ ]),
257
+ error ? h("div", { className: "dshe-alert error" }, error) : null,
258
+ message ? h("div", { className: "dshe-alert success" }, message) : null,
259
+ testResult
260
+ ? h("div", { className: testResult.ok ? "dshe-alert success" : "dshe-alert error" }, testResult.text)
261
+ : null,
262
+ accounts.length > 0
263
+ ? h("div", { className: "dshe-hint" }, "当前生效账号:" + accounts.join("、") + "(多账号 accounts 映射仍走 cordis.patch.yml)")
264
+ : null,
265
+ ]);
266
+ }
267
+
268
+ const inject = ["slots"];
269
+
270
+ function apply(ctx) {
271
+ ctx.effect(() => {
272
+ const id = "dsh-email/client";
273
+ if (document.querySelector('style[data-plugin-css="' + id + '"]')) return () => {};
274
+ const style = document.createElement("style");
275
+ style.dataset.plugin = "dsh-email";
276
+ style.dataset.pluginCss = id;
277
+ style.textContent = CSS;
278
+ document.head.appendChild(style);
279
+ return () => { style.remove(); };
280
+ }, "dsh-email: styles");
281
+
282
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
283
+ name: "settings.section",
284
+ id: "dsh-email",
285
+ order: 45,
286
+ label: () => "邮件 (dsh-email)",
287
+ inject: () => ({}),
288
+ }, EmailSettingsSection));
289
+ }
290
+
291
+ exports.apply = apply;
292
+ exports.inject = inject;
293
+
294
+ return module.exports;
295
+ }});
package/lib/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type ProviderName = 'qq' | '163' | '126' | 'sina' | 'aliyun' | 'gmail' | 'outlook' | 'icloud';
1
2
  export interface ImapConfig {
2
3
  host?: string;
3
4
  port?: number;
@@ -10,21 +11,30 @@ export interface SmtpConfig {
10
11
  port?: number;
11
12
  secure?: boolean;
12
13
  }
13
- export interface EmailConfig {
14
- /** Built-in preset that fills imap/smtp host+port+secure. */
15
- provider?: 'qq' | '163' | '126' | 'sina' | 'aliyun' | 'gmail' | 'outlook' | 'icloud';
16
- /** Login address, e.g. you@qq.com. */
14
+ /** One mailbox account. Top-level shorthand fields act as shared defaults. */
15
+ export interface AccountConfig {
16
+ provider?: ProviderName;
17
17
  user?: string;
18
- /** App password / authorization code. Falls back to $DSH_EMAIL_PASSWORD. */
19
18
  password?: string;
20
19
  imap?: ImapConfig;
21
20
  smtp?: SmtpConfig;
22
- /** Mailbox used by the read/search/list tools. Default 'INBOX'. */
23
21
  inboxFolder?: string;
22
+ }
23
+ export interface EmailConfig extends AccountConfig {
24
24
  /** Ask the user for approval before email_send. Default true. */
25
25
  sendApproval?: boolean;
26
26
  /** Plain-text body cap for email_read. Default 20000. */
27
27
  maxBodyChars?: number;
28
+ /** Named accounts. Account-level fields override the top-level shorthand. */
29
+ accounts?: Record<string, AccountConfig>;
30
+ /** Which account tools use when the call omits account. Required with 2+ accounts. */
31
+ defaultAccount?: string;
32
+ /** Directory email_attachment writes into. Default $DSH_HOME/email-downloads. */
33
+ downloadDir?: string;
34
+ /** Per-attachment and total-attachment byte cap. Default 20 MiB. */
35
+ maxAttachmentBytes?: number;
36
+ /** Unused IMAP connections close after this many ms. Default 60000. */
37
+ idleTimeoutMs?: number;
28
38
  }
29
39
  export interface ProviderPreset {
30
40
  imap: {
@@ -41,7 +51,7 @@ export interface ProviderPreset {
41
51
  export declare const PROVIDER_PRESETS: Record<string, ProviderPreset>;
42
52
  export declare const PROVIDER_NAMES: string[];
43
53
  export declare const EMAIL_PASSWORD_ENV = "DSH_EMAIL_PASSWORD";
44
- /** Fully resolved, validated configuration. */
54
+ /** Fully resolved, validated configuration for one account. */
45
55
  export interface ResolvedEmailConfig {
46
56
  user: string;
47
57
  password: string;
@@ -56,14 +66,24 @@ export interface ResolvedEmailConfig {
56
66
  secure: boolean;
57
67
  };
58
68
  inboxFolder: string;
69
+ }
70
+ /** Fully resolved plugin settings: the account map plus shared policy. */
71
+ export interface ResolvedEmailSettings {
72
+ accounts: Map<string, ResolvedEmailConfig>;
73
+ defaultAccount: string;
59
74
  sendApproval: boolean;
60
75
  maxBodyChars: number;
61
- providerName: string | null;
76
+ downloadDir: string;
77
+ maxAttachmentBytes: number;
78
+ idleTimeoutMs: number;
62
79
  }
80
+ export declare function defaultDownloadDir(): string;
63
81
  /**
64
82
  * Resolve and validate the raw row config. Throws with an actionable message
65
83
  * (in Chinese, since it is what the user and the model both read) when the
66
84
  * account is not fully specified.
67
85
  */
86
+ export declare function resolveEmailSettings(config: EmailConfig | undefined): ResolvedEmailSettings;
87
+ /** v0.1-compatible wrapper: resolve the single (or default) account. */
68
88
  export declare function resolveEmailConfig(config: EmailConfig | undefined): ResolvedEmailConfig;
69
89
  export declare function clampInt(value: unknown, fallback: number, min: number, max: number): number;
package/lib/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
1
3
  export const PROVIDER_PRESETS = {
2
4
  qq: { imap: { host: 'imap.qq.com', port: 993, secure: true }, smtp: { host: 'smtp.qq.com', port: 465, secure: true } },
3
5
  '163': { imap: { host: 'imap.163.com', port: 993, secure: true }, smtp: { host: 'smtp.163.com', port: 465, secure: true } },
@@ -10,40 +12,94 @@ export const PROVIDER_PRESETS = {
10
12
  };
11
13
  export const PROVIDER_NAMES = Object.keys(PROVIDER_PRESETS);
12
14
  export const EMAIL_PASSWORD_ENV = 'DSH_EMAIL_PASSWORD';
15
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
16
+ const DEFAULT_IDLE_TIMEOUT_MS = 60000;
17
+ export function defaultDownloadDir() {
18
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
19
+ return join(home, 'email-downloads');
20
+ }
13
21
  /**
14
22
  * Resolve and validate the raw row config. Throws with an actionable message
15
23
  * (in Chinese, since it is what the user and the model both read) when the
16
24
  * account is not fully specified.
17
25
  */
18
- export function resolveEmailConfig(config) {
26
+ export function resolveEmailSettings(config) {
19
27
  const raw = config ?? {};
20
- const preset = raw.provider === undefined ? undefined : PROVIDER_PRESETS[raw.provider];
21
- if (raw.provider !== undefined && preset === undefined) {
22
- throw new Error(`dsh-email:未知的邮箱服务商 "${raw.provider}",可选:${PROVIDER_NAMES.join('/')};或省略 provider,直接填写 imap.host 与 smtp.host`);
28
+ const common = {
29
+ provider: raw.provider,
30
+ user: raw.user,
31
+ password: raw.password,
32
+ imap: raw.imap,
33
+ smtp: raw.smtp,
34
+ inboxFolder: raw.inboxFolder,
35
+ };
36
+ const entries = raw.accounts === undefined || Object.keys(raw.accounts).length === 0
37
+ ? undefined
38
+ : raw.accounts;
39
+ const accounts = new Map();
40
+ if (entries === undefined) {
41
+ accounts.set('default', resolveAccount('default', common, {}, true));
42
+ }
43
+ else {
44
+ for (const [name, acc] of Object.entries(entries)) {
45
+ accounts.set(name, resolveAccount(name, common, acc ?? {}, false));
46
+ }
47
+ }
48
+ let defaultName;
49
+ if (raw.defaultAccount !== undefined && raw.defaultAccount !== '') {
50
+ if (!accounts.has(raw.defaultAccount)) {
51
+ throw new Error(`dsh-email:defaultAccount "${raw.defaultAccount}" 不存在,可用账号:${[...accounts.keys()].join('、')}`);
52
+ }
53
+ defaultName = raw.defaultAccount;
54
+ }
55
+ else if (accounts.size === 1) {
56
+ defaultName = [...accounts.keys()][0];
57
+ }
58
+ else if (accounts.has('default')) {
59
+ defaultName = 'default';
60
+ }
61
+ else {
62
+ throw new Error(`dsh-email:配置了多个账号(${[...accounts.keys()].join('、')}),请设置 defaultAccount 指定默认账号`);
63
+ }
64
+ return {
65
+ accounts,
66
+ defaultAccount: defaultName,
67
+ sendApproval: raw.sendApproval !== false,
68
+ maxBodyChars: clampInt(raw.maxBodyChars, 20000, 1000, 200000),
69
+ downloadDir: raw.downloadDir?.trim() || defaultDownloadDir(),
70
+ maxAttachmentBytes: clampInt(raw.maxAttachmentBytes, DEFAULT_MAX_ATTACHMENT_BYTES, 1024, 512 * 1024 * 1024),
71
+ idleTimeoutMs: clampInt(raw.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS, 5000, 600000),
72
+ };
73
+ }
74
+ /** Merge one account over the shared shorthand and validate it. */
75
+ function resolveAccount(name, common, acc, allowEnvPassword) {
76
+ const preset = acc.provider === undefined ? PROVIDER_PRESETS[common.provider ?? ''] : PROVIDER_PRESETS[acc.provider];
77
+ if ((acc.provider ?? common.provider) !== undefined && preset === undefined) {
78
+ throw new Error(`dsh-email:账号 "${name}" 的 provider "${acc.provider ?? common.provider}" 未知,可选:${PROVIDER_NAMES.join('/')};或省略 provider 直接填 imap.host 与 smtp.host`);
23
79
  }
24
- const user = raw.user?.trim() ?? '';
25
- const password = raw.password ?? process.env[EMAIL_PASSWORD_ENV] ?? '';
80
+ const user = (acc.user ?? common.user ?? '').trim();
81
+ const password = acc.password ?? common.password ?? (allowEnvPassword ? process.env[EMAIL_PASSWORD_ENV] ?? '' : '');
26
82
  const imap = {
27
- host: raw.imap?.host ?? preset?.imap.host,
28
- port: raw.imap?.port ?? preset?.imap.port,
29
- secure: raw.imap?.secure ?? preset?.imap.secure,
30
- connectionTimeoutMs: raw.imap?.connectionTimeoutMs,
31
- socketTimeoutMs: raw.imap?.socketTimeoutMs,
83
+ host: acc.imap?.host ?? common.imap?.host ?? preset?.imap.host,
84
+ port: acc.imap?.port ?? common.imap?.port ?? preset?.imap.port,
85
+ secure: acc.imap?.secure ?? common.imap?.secure ?? preset?.imap.secure,
86
+ connectionTimeoutMs: acc.imap?.connectionTimeoutMs ?? common.imap?.connectionTimeoutMs,
87
+ socketTimeoutMs: acc.imap?.socketTimeoutMs ?? common.imap?.socketTimeoutMs,
32
88
  };
33
89
  const smtp = {
34
- host: raw.smtp?.host ?? preset?.smtp.host,
35
- port: raw.smtp?.port ?? preset?.smtp.port,
36
- secure: raw.smtp?.secure ?? preset?.smtp.secure,
90
+ host: acc.smtp?.host ?? common.smtp?.host ?? preset?.smtp.host,
91
+ port: acc.smtp?.port ?? common.smtp?.port ?? preset?.smtp.port,
92
+ secure: acc.smtp?.secure ?? common.smtp?.secure ?? preset?.smtp.secure,
37
93
  };
38
94
  const problems = [];
39
95
  if (user === '')
40
- problems.push('user(邮箱地址)未填写');
96
+ problems.push(`账号 "${name}" 的 user(邮箱地址)未填写`);
41
97
  if (password === '')
42
- problems.push(`password 未填写(或用环境变量 ${EMAIL_PASSWORD_ENV})`);
98
+ problems.push(`账号 "${name}" 的 password 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
43
99
  if (imap.host === undefined || imap.host === '')
44
- problems.push(`imap.host 未填写(可填 provider 预设:${PROVIDER_NAMES.join('/')})`);
100
+ problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${PROVIDER_NAMES.join('/')})`);
45
101
  if (smtp.host === undefined || smtp.host === '')
46
- problems.push('smtp.host 未填写(同上)');
102
+ problems.push(`账号 "${name}" 的 smtp.host 未填写(同上)`);
47
103
  if (problems.length > 0) {
48
104
  throw new Error(`dsh-email 未配置:${problems.join(';')}。请在 profile 的 cordis.patch.yml 中覆盖 tool-email 行并重启(见插件 README)`);
49
105
  }
@@ -52,12 +108,14 @@ export function resolveEmailConfig(config) {
52
108
  password,
53
109
  imap: { ...imap, host: imap.host, port: imap.port, secure: imap.secure },
54
110
  smtp: { ...smtp, host: smtp.host, port: smtp.port, secure: smtp.secure },
55
- inboxFolder: raw.inboxFolder?.trim() || 'INBOX',
56
- sendApproval: raw.sendApproval !== false,
57
- maxBodyChars: clampInt(raw.maxBodyChars, 20000, 1000, 200000),
58
- providerName: raw.provider ?? null,
111
+ inboxFolder: (acc.inboxFolder ?? common.inboxFolder ?? '').trim() || 'INBOX',
59
112
  };
60
113
  }
114
+ /** v0.1-compatible wrapper: resolve the single (or default) account. */
115
+ export function resolveEmailConfig(config) {
116
+ const settings = resolveEmailSettings(config);
117
+ return settings.accounts.get(settings.defaultAccount);
118
+ }
61
119
  export function clampInt(value, fallback, min, max) {
62
120
  const n = typeof value === 'number' ? Math.trunc(value) : fallback;
63
121
  if (!Number.isFinite(n))
package/lib/index.d.ts CHANGED
@@ -4,6 +4,6 @@ export declare const inject: string[];
4
4
  export type Config = EmailConfig;
5
5
  export declare function apply(ctx: any, config?: Config): void;
6
6
  export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
7
- export { resolveEmailConfig, clampInt } from './config.js';
8
- export { stripHtml, truncateText, flattenAddresses, parseRawMessage } from './parse.js';
9
- export { MailClient, MailError, messageOf } from './mail-client.js';
7
+ export { resolveEmailConfig, resolveEmailSettings, clampInt, defaultDownloadDir } from './config.js';
8
+ export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
9
+ export { EmailPool, MailError, messageOf, validateAttachmentPaths } from './mail-client.js';