dsh-email 0.2.0 → 0.3.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/README.md +10 -3
- package/lib/client.js +295 -0
- package/lib/index.js +85 -29
- package/lib/settings.d.ts +91 -0
- package/lib/settings.js +92 -0
- package/lib/web.d.ts +42 -0
- package/lib/web.js +138 -0
- package/package.json +66 -62
package/README.md
CHANGED
|
@@ -29,7 +29,14 @@ dsh plugin --profile web add dsh-email
|
|
|
29
29
|
|
|
30
30
|
(或从 GitHub 安装:`dsh plugin --profile web add github:你的账号/dsh-email#<commit>`,随后按提示在 profile 的 `pnpm-workspace.yaml` 里授权 `prepare` 构建。)
|
|
31
31
|
|
|
32
|
-
装好后重启 `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(不会出现在任何导出/诊断里)。
|
|
33
40
|
|
|
34
41
|
## 配置
|
|
35
42
|
|
|
@@ -119,7 +126,7 @@ dsh plugin --profile web add dsh-email
|
|
|
119
126
|
## 已知限制(v0.2)
|
|
120
127
|
|
|
121
128
|
- **连接复用**:IMAP 按账号池化(空闲自动回收),SMTP 用 nodemailer 连接池;同一账号的并发调用会排队串行(一个连接一次只服务一个操作,这是有意的)。
|
|
122
|
-
- **多账号**:每个账号独立连接池;一个 `tool-email`
|
|
129
|
+
- **多账号**:每个账号独立连接池;一个 `tool-email` 行可以配任意多个账号。设置页编辑的是默认账号;`accounts` 映射仍需写 cordis.patch.yml。
|
|
123
130
|
- **附件下载**:email_attachment 按序号下载(与 email_read 的 attachments 顺序一致);文件名会被清洗防路径穿越,已有同名文件自动加后缀,大小受 maxAttachmentBytes 限制。
|
|
124
131
|
- **不支持 OAuth2**:强制 OAuth 的企业环境(部分 M365/Google Workspace)暂不可用。
|
|
125
132
|
- 正文搜索不提供:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索要么全量匹配要么不支持,所以只搜主题/发件人/收件人;正文搜索列入后续版本(需客户端下载解析,较慢)。
|
|
@@ -129,7 +136,7 @@ dsh plugin --profile web add dsh-email
|
|
|
129
136
|
```sh
|
|
130
137
|
pnpm install
|
|
131
138
|
pnpm run build # tsc → lib/
|
|
132
|
-
pnpm test # 构建 + node --test(配置/解析/注册与审批门,
|
|
139
|
+
pnpm test # 构建 + node --test(配置/解析/注册与审批门,31 个用例,无需真实邮箱)
|
|
133
140
|
```
|
|
134
141
|
|
|
135
142
|
## 协议
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-email", 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/index.js
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import { clampInt, resolveEmailSettings } from './config.js';
|
|
2
2
|
import { EmailPool, messageOf } from './mail-client.js';
|
|
3
|
+
import { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
|
|
4
|
+
import { EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
|
3
5
|
export const name = 'tool-email';
|
|
4
|
-
export const inject = ['tools'];
|
|
6
|
+
export const inject = ['settings', 'tools'];
|
|
5
7
|
const MAX_LIMIT = 100;
|
|
6
8
|
const ACCOUNT_HINT = '账号名(配置了 accounts 多个账号时选择),省略时用 defaultAccount。可用账号见 email_folders 的报错或插件 README';
|
|
9
|
+
/**
|
|
10
|
+
* Compile the author DSL map into a raw JSON Schema object, exactly what
|
|
11
|
+
* defineTool stores as definition.parameters. The native wire request sends
|
|
12
|
+
* this value verbatim, so a raw DSL here would be rejected by the model API
|
|
13
|
+
* ("schema must be a JSON Schema of 'type: object'").
|
|
14
|
+
*/
|
|
15
|
+
function compileParameters(spec) {
|
|
16
|
+
const properties = {};
|
|
17
|
+
const required = [];
|
|
18
|
+
for (const [key, prop] of Object.entries(spec)) {
|
|
19
|
+
if (prop?.required === true)
|
|
20
|
+
required.push(key);
|
|
21
|
+
const node = {};
|
|
22
|
+
if (typeof prop?.type === 'string')
|
|
23
|
+
node.type = prop.type;
|
|
24
|
+
if (typeof prop?.description === 'string')
|
|
25
|
+
node.description = prop.description;
|
|
26
|
+
if (prop?.type === 'array' && prop.items !== null && typeof prop.items === 'object') {
|
|
27
|
+
const items = { type: 'string' };
|
|
28
|
+
if (prop.items.type === 'object')
|
|
29
|
+
items.additionalProperties = true;
|
|
30
|
+
node.items = items;
|
|
31
|
+
}
|
|
32
|
+
properties[key] = node;
|
|
33
|
+
}
|
|
34
|
+
return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
|
|
35
|
+
}
|
|
7
36
|
const strArray = { type: 'array', items: { type: 'string' } };
|
|
8
37
|
const addrArray = { type: 'array', items: { type: 'object', additionalProperties: true } };
|
|
9
38
|
const messageShape = {
|
|
@@ -135,40 +164,62 @@ function renderFolders(value) {
|
|
|
135
164
|
function renderAttachment(value) {
|
|
136
165
|
return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
|
|
137
166
|
}
|
|
167
|
+
function fingerprintSettings(settings) {
|
|
168
|
+
return JSON.stringify({
|
|
169
|
+
accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
|
|
170
|
+
defaultAccount: settings.defaultAccount,
|
|
171
|
+
sendApproval: settings.sendApproval,
|
|
172
|
+
maxBodyChars: settings.maxBodyChars,
|
|
173
|
+
downloadDir: settings.downloadDir,
|
|
174
|
+
maxAttachmentBytes: settings.maxAttachmentBytes,
|
|
175
|
+
idleTimeoutMs: settings.idleTimeoutMs,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
138
178
|
export function apply(ctx, config = {}) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
179
|
+
// The settings namespace is the single source for the default account: the
|
|
180
|
+
// row config (cordis.patch.yml) is its base layer, the Web settings page
|
|
181
|
+
// writes the user layer, and resolveEmailSettings merges both at use time.
|
|
182
|
+
const settingsScope = ctx.settings.register(SETTINGS_NAMESPACE, EmailSettingsSchema, {
|
|
183
|
+
base: toSettingsBase(config),
|
|
184
|
+
applies: 'live',
|
|
185
|
+
validate: (value) => validateSettingsValue(value),
|
|
186
|
+
});
|
|
148
187
|
let pool = null;
|
|
188
|
+
let poolFingerprint = '';
|
|
149
189
|
const getPool = () => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
190
|
+
const value = settingsScope.get();
|
|
191
|
+
const effective = resolveEmailSettings({ ...config, ...toEmailConfig(value) });
|
|
192
|
+
const fp = fingerprintSettings(effective);
|
|
193
|
+
if (pool === null || fp !== poolFingerprint) {
|
|
194
|
+
pool?.dispose();
|
|
195
|
+
pool = new EmailPool(effective);
|
|
154
196
|
pool.startIdleSweep();
|
|
197
|
+
poolFingerprint = fp;
|
|
155
198
|
}
|
|
156
199
|
return pool;
|
|
157
200
|
};
|
|
201
|
+
// Load-time nudge only: never break boot, tools report the details instead.
|
|
202
|
+
try {
|
|
203
|
+
getPool();
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
ctx.logger?.warn?.('[dsh-email] ' + messageOf(error, '未配置邮箱账号'));
|
|
207
|
+
}
|
|
158
208
|
ctx.effect(() => () => {
|
|
159
209
|
pool?.dispose();
|
|
160
210
|
pool = null;
|
|
161
211
|
});
|
|
212
|
+
installEmailSettingsWeb(ctx, new EmailSettingsBackend(ctx, settingsScope, config));
|
|
162
213
|
ctx.tools.register({
|
|
163
214
|
name: 'email_list',
|
|
164
215
|
description: 'List recent emails in a mailbox folder (newest first). Returns uid, date, sender, subject and flags without message bodies; use email_read with a uid to fetch the full text.',
|
|
165
|
-
parameters: {
|
|
216
|
+
parameters: compileParameters({
|
|
166
217
|
folder: { type: 'string', description: 'IMAP folder path (see email_folders); defaults to the account inboxFolder' },
|
|
167
218
|
limit: { type: 'integer', description: 'How many messages to return, 1-100, default 20' },
|
|
168
219
|
offset: { type: 'integer', description: 'Skip this many newest messages first, default 0' },
|
|
169
220
|
unreadOnly: { type: 'boolean', description: 'Only list unread messages, default false' },
|
|
170
221
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
171
|
-
},
|
|
222
|
+
}),
|
|
172
223
|
output: {
|
|
173
224
|
schema: listSchema,
|
|
174
225
|
render: (_args, value) => renderList(value),
|
|
@@ -183,11 +234,11 @@ export function apply(ctx, config = {}) {
|
|
|
183
234
|
ctx.tools.register({
|
|
184
235
|
name: 'email_read',
|
|
185
236
|
description: '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.',
|
|
186
|
-
parameters: {
|
|
237
|
+
parameters: compileParameters({
|
|
187
238
|
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
188
239
|
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
189
240
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
190
|
-
},
|
|
241
|
+
}),
|
|
191
242
|
output: {
|
|
192
243
|
schema: readSchema,
|
|
193
244
|
render: (_args, value) => renderRead(value),
|
|
@@ -203,12 +254,12 @@ export function apply(ctx, config = {}) {
|
|
|
203
254
|
ctx.tools.register({
|
|
204
255
|
name: 'email_search',
|
|
205
256
|
description: 'Search emails by a keyword matched against sender, recipient and subject (server-side IMAP SEARCH). Body search is not supported by every server and is not attempted; returns the same compact rows as email_list.',
|
|
206
|
-
parameters: {
|
|
257
|
+
parameters: compileParameters({
|
|
207
258
|
query: { type: 'string', required: true, description: 'Keyword to search for' },
|
|
208
259
|
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
|
|
209
260
|
limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
|
|
210
261
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
211
|
-
},
|
|
262
|
+
}),
|
|
212
263
|
output: {
|
|
213
264
|
schema: listSchema,
|
|
214
265
|
render: (_args, value) => renderSearch(value),
|
|
@@ -224,14 +275,14 @@ export function apply(ctx, config = {}) {
|
|
|
224
275
|
ctx.tools.register({
|
|
225
276
|
name: 'email_send',
|
|
226
277
|
description: 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the dsh process cwd). Sending first asks the user for approval (recipient, subject and attachment count are shown) unless sendApproval is disabled; never invent recipients or content without the user\'s instruction.',
|
|
227
|
-
parameters: {
|
|
278
|
+
parameters: compileParameters({
|
|
228
279
|
to: { type: 'string', required: true, description: 'Recipient(s), comma-separated' },
|
|
229
280
|
subject: { type: 'string', required: true, description: 'Email subject' },
|
|
230
281
|
text: { type: 'string', description: 'Plain-text body' },
|
|
231
282
|
cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
|
|
232
283
|
attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the dsh process cwd)' },
|
|
233
284
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
234
|
-
},
|
|
285
|
+
}),
|
|
235
286
|
output: {
|
|
236
287
|
schema: sendSchema,
|
|
237
288
|
render: (_args, value) => renderSend(value),
|
|
@@ -248,10 +299,10 @@ export function apply(ctx, config = {}) {
|
|
|
248
299
|
ctx.tools.register({
|
|
249
300
|
name: 'email_folders',
|
|
250
301
|
description: '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.',
|
|
251
|
-
parameters: {
|
|
302
|
+
parameters: compileParameters({
|
|
252
303
|
subscribedOnly: { type: 'boolean', description: 'Only subscribed folders, default false' },
|
|
253
304
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
254
|
-
},
|
|
305
|
+
}),
|
|
255
306
|
output: {
|
|
256
307
|
schema: foldersSchema,
|
|
257
308
|
render: (_args, value) => renderFolders(value),
|
|
@@ -264,12 +315,12 @@ export function apply(ctx, config = {}) {
|
|
|
264
315
|
ctx.tools.register({
|
|
265
316
|
name: 'email_attachment',
|
|
266
317
|
description: '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.',
|
|
267
|
-
parameters: {
|
|
318
|
+
parameters: compileParameters({
|
|
268
319
|
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
269
320
|
index: { type: 'integer', description: '0-based attachment index, as listed by email_read; default 0' },
|
|
270
321
|
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
271
322
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
272
|
-
},
|
|
323
|
+
}),
|
|
273
324
|
output: {
|
|
274
325
|
schema: attachmentSchema,
|
|
275
326
|
render: (_args, value) => renderAttachment(value),
|
|
@@ -289,10 +340,15 @@ export function apply(ctx, config = {}) {
|
|
|
289
340
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
290
341
|
if (exec?.name !== 'email_send')
|
|
291
342
|
return next();
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (settingsError !== '')
|
|
343
|
+
const value = settingsScope.get();
|
|
344
|
+
if (value.sendApproval === false)
|
|
295
345
|
return next();
|
|
346
|
+
try {
|
|
347
|
+
resolveEmailSettings({ ...config, ...toEmailConfig(value) });
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return next(); // unconfigured: let the tool report the actionable hint
|
|
351
|
+
}
|
|
296
352
|
const args = (exec.args ?? {});
|
|
297
353
|
const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
|
|
298
354
|
const reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import z from 'schemastery';
|
|
2
|
+
import { type EmailConfig } from './config.js';
|
|
3
|
+
/** Settings-document namespace this plugin owns (editable from the Web settings page). */
|
|
4
|
+
export declare const SETTINGS_NAMESPACE = "dsh-email";
|
|
5
|
+
/**
|
|
6
|
+
* The settings-page shape: the single default account plus shared policy.
|
|
7
|
+
* Multi-account (`accounts` map) stays YAML-only; the page edits the
|
|
8
|
+
* default/shorthand account.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EmailSettingsSchema: z<Schemastery.ObjectS<{
|
|
11
|
+
provider: z<string, string>;
|
|
12
|
+
user: z<string, string>;
|
|
13
|
+
password: z<string, string>;
|
|
14
|
+
inboxFolder: z<string, string>;
|
|
15
|
+
sendApproval: z<boolean, boolean>;
|
|
16
|
+
maxBodyChars: z<number, number>;
|
|
17
|
+
downloadDir: z<string, string>;
|
|
18
|
+
imap: z<Schemastery.ObjectS<{
|
|
19
|
+
host: z<string, string>;
|
|
20
|
+
port: z<number, number>;
|
|
21
|
+
secure: z<boolean, boolean>;
|
|
22
|
+
}>, Schemastery.ObjectT<{
|
|
23
|
+
host: z<string, string>;
|
|
24
|
+
port: z<number, number>;
|
|
25
|
+
secure: z<boolean, boolean>;
|
|
26
|
+
}>>;
|
|
27
|
+
smtp: z<Schemastery.ObjectS<{
|
|
28
|
+
host: z<string, string>;
|
|
29
|
+
port: z<number, number>;
|
|
30
|
+
secure: z<boolean, boolean>;
|
|
31
|
+
}>, Schemastery.ObjectT<{
|
|
32
|
+
host: z<string, string>;
|
|
33
|
+
port: z<number, number>;
|
|
34
|
+
secure: z<boolean, boolean>;
|
|
35
|
+
}>>;
|
|
36
|
+
}>, Schemastery.ObjectT<{
|
|
37
|
+
provider: z<string, string>;
|
|
38
|
+
user: z<string, string>;
|
|
39
|
+
password: z<string, string>;
|
|
40
|
+
inboxFolder: z<string, string>;
|
|
41
|
+
sendApproval: z<boolean, boolean>;
|
|
42
|
+
maxBodyChars: z<number, number>;
|
|
43
|
+
downloadDir: z<string, string>;
|
|
44
|
+
imap: z<Schemastery.ObjectS<{
|
|
45
|
+
host: z<string, string>;
|
|
46
|
+
port: z<number, number>;
|
|
47
|
+
secure: z<boolean, boolean>;
|
|
48
|
+
}>, Schemastery.ObjectT<{
|
|
49
|
+
host: z<string, string>;
|
|
50
|
+
port: z<number, number>;
|
|
51
|
+
secure: z<boolean, boolean>;
|
|
52
|
+
}>>;
|
|
53
|
+
smtp: z<Schemastery.ObjectS<{
|
|
54
|
+
host: z<string, string>;
|
|
55
|
+
port: z<number, number>;
|
|
56
|
+
secure: z<boolean, boolean>;
|
|
57
|
+
}>, Schemastery.ObjectT<{
|
|
58
|
+
host: z<string, string>;
|
|
59
|
+
port: z<number, number>;
|
|
60
|
+
secure: z<boolean, boolean>;
|
|
61
|
+
}>>;
|
|
62
|
+
}>>;
|
|
63
|
+
export interface EmailSettingsValue {
|
|
64
|
+
provider: string;
|
|
65
|
+
user: string;
|
|
66
|
+
password: string;
|
|
67
|
+
inboxFolder: string;
|
|
68
|
+
sendApproval: boolean;
|
|
69
|
+
maxBodyChars: number;
|
|
70
|
+
downloadDir: string;
|
|
71
|
+
imap: {
|
|
72
|
+
host: string;
|
|
73
|
+
port: number;
|
|
74
|
+
secure: boolean;
|
|
75
|
+
};
|
|
76
|
+
smtp: {
|
|
77
|
+
host: string;
|
|
78
|
+
port: number;
|
|
79
|
+
secure: boolean;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Project the row config (cordis.patch.yml) into the settings-schema base shape. */
|
|
83
|
+
export declare function toSettingsBase(config: EmailConfig): Partial<EmailSettingsValue>;
|
|
84
|
+
/** Project a settings value back into EmailConfig shape ('' becomes unset). */
|
|
85
|
+
export declare function toEmailConfig(value: EmailSettingsValue): EmailConfig;
|
|
86
|
+
/**
|
|
87
|
+
* Gentle write-path validation: structural mistakes fail loudly, but an
|
|
88
|
+
* incomplete account is allowed (tools report the actionable hint at call
|
|
89
|
+
* time, so an unconfigured install never breaks boot).
|
|
90
|
+
*/
|
|
91
|
+
export declare function validateSettingsValue(value: EmailSettingsValue): void;
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import z from 'schemastery';
|
|
2
|
+
import { PROVIDER_NAMES } from './config.js';
|
|
3
|
+
/** Settings-document namespace this plugin owns (editable from the Web settings page). */
|
|
4
|
+
export const SETTINGS_NAMESPACE = 'dsh-email';
|
|
5
|
+
/**
|
|
6
|
+
* The settings-page shape: the single default account plus shared policy.
|
|
7
|
+
* Multi-account (`accounts` map) stays YAML-only; the page edits the
|
|
8
|
+
* default/shorthand account.
|
|
9
|
+
*/
|
|
10
|
+
export const EmailSettingsSchema = z.object({
|
|
11
|
+
provider: z.string().default(''),
|
|
12
|
+
user: z.string().default(''),
|
|
13
|
+
password: z.string().role('secret').default(''),
|
|
14
|
+
inboxFolder: z.string().default('INBOX'),
|
|
15
|
+
sendApproval: z.boolean().default(true),
|
|
16
|
+
maxBodyChars: z.number().default(20000),
|
|
17
|
+
downloadDir: z.string().default(''),
|
|
18
|
+
imap: z.object({
|
|
19
|
+
host: z.string().default(''),
|
|
20
|
+
port: z.number().default(993),
|
|
21
|
+
secure: z.boolean().default(true),
|
|
22
|
+
}),
|
|
23
|
+
smtp: z.object({
|
|
24
|
+
host: z.string().default(''),
|
|
25
|
+
port: z.number().default(465),
|
|
26
|
+
secure: z.boolean().default(true),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
/** Project the row config (cordis.patch.yml) into the settings-schema base shape. */
|
|
30
|
+
export function toSettingsBase(config) {
|
|
31
|
+
return {
|
|
32
|
+
...(config.provider !== undefined ? { provider: config.provider } : {}),
|
|
33
|
+
...(config.user !== undefined && config.user !== '' ? { user: config.user } : {}),
|
|
34
|
+
...(config.password !== undefined && config.password !== '' ? { password: config.password } : {}),
|
|
35
|
+
...(config.inboxFolder !== undefined && config.inboxFolder !== '' ? { inboxFolder: config.inboxFolder } : {}),
|
|
36
|
+
...(config.sendApproval !== undefined ? { sendApproval: config.sendApproval } : {}),
|
|
37
|
+
...(config.maxBodyChars !== undefined ? { maxBodyChars: config.maxBodyChars } : {}),
|
|
38
|
+
...(config.downloadDir !== undefined && config.downloadDir !== '' ? { downloadDir: config.downloadDir } : {}),
|
|
39
|
+
...(config.imap !== undefined ? {
|
|
40
|
+
imap: {
|
|
41
|
+
host: config.imap.host ?? '',
|
|
42
|
+
port: config.imap.port ?? 993,
|
|
43
|
+
secure: config.imap.secure ?? true,
|
|
44
|
+
},
|
|
45
|
+
} : {}),
|
|
46
|
+
...(config.smtp !== undefined ? {
|
|
47
|
+
smtp: {
|
|
48
|
+
host: config.smtp.host ?? '',
|
|
49
|
+
port: config.smtp.port ?? 465,
|
|
50
|
+
secure: config.smtp.secure ?? true,
|
|
51
|
+
},
|
|
52
|
+
} : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Project a settings value back into EmailConfig shape ('' becomes unset). */
|
|
56
|
+
export function toEmailConfig(value) {
|
|
57
|
+
return {
|
|
58
|
+
...(value.provider !== '' ? { provider: value.provider } : {}),
|
|
59
|
+
...(value.user !== '' ? { user: value.user } : {}),
|
|
60
|
+
...(value.password !== '' ? { password: value.password } : {}),
|
|
61
|
+
...(value.inboxFolder !== '' && value.inboxFolder !== 'INBOX' ? { inboxFolder: value.inboxFolder } : {}),
|
|
62
|
+
sendApproval: value.sendApproval,
|
|
63
|
+
maxBodyChars: value.maxBodyChars,
|
|
64
|
+
...(value.downloadDir !== '' ? { downloadDir: value.downloadDir } : {}),
|
|
65
|
+
imap: {
|
|
66
|
+
...(value.imap.host !== '' ? { host: value.imap.host } : {}),
|
|
67
|
+
port: value.imap.port,
|
|
68
|
+
secure: value.imap.secure,
|
|
69
|
+
},
|
|
70
|
+
smtp: {
|
|
71
|
+
...(value.smtp.host !== '' ? { host: value.smtp.host } : {}),
|
|
72
|
+
port: value.smtp.port,
|
|
73
|
+
secure: value.smtp.secure,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Gentle write-path validation: structural mistakes fail loudly, but an
|
|
79
|
+
* incomplete account is allowed (tools report the actionable hint at call
|
|
80
|
+
* time, so an unconfigured install never breaks boot).
|
|
81
|
+
*/
|
|
82
|
+
export function validateSettingsValue(value) {
|
|
83
|
+
if (value.provider !== '' && !PROVIDER_NAMES.includes(value.provider)) {
|
|
84
|
+
throw new Error('未知的邮箱服务商 "' + value.provider + '",可选:' + PROVIDER_NAMES.join('/') + '(或留空手填 IMAP/SMTP 主机)');
|
|
85
|
+
}
|
|
86
|
+
if (value.imap.port < 1 || value.imap.port > 65535)
|
|
87
|
+
throw new Error('IMAP 端口必须在 1-65535 之间');
|
|
88
|
+
if (value.smtp.port < 1 || value.smtp.port > 65535)
|
|
89
|
+
throw new Error('SMTP 端口必须在 1-65535 之间');
|
|
90
|
+
if (value.maxBodyChars < 1000 || value.maxBodyChars > 200000)
|
|
91
|
+
throw new Error('正文截断上限必须在 1000-200000 之间');
|
|
92
|
+
}
|
package/lib/web.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type EmailSettingsValue } from './settings.js';
|
|
2
|
+
import { type EmailConfig } from './config.js';
|
|
3
|
+
/** Same-origin route the browser settings section talks to. */
|
|
4
|
+
export declare const SETTINGS_ROUTE = "/_dsh/dsh-email/settings";
|
|
5
|
+
/**
|
|
6
|
+
* Browser-facing backend: snapshot the settings namespace, save it with
|
|
7
|
+
* optimistic concurrency, and test a draft account over a live IMAP login.
|
|
8
|
+
*/
|
|
9
|
+
export declare class EmailSettingsBackend {
|
|
10
|
+
private readonly ctx;
|
|
11
|
+
private readonly scope;
|
|
12
|
+
private readonly rowConfig;
|
|
13
|
+
constructor(ctx: any, scope: any, rowConfig: EmailConfig);
|
|
14
|
+
private effective;
|
|
15
|
+
snapshot(): Promise<{
|
|
16
|
+
settings: {
|
|
17
|
+
value: EmailSettingsValue;
|
|
18
|
+
revision: any;
|
|
19
|
+
applies: any;
|
|
20
|
+
};
|
|
21
|
+
writable: boolean;
|
|
22
|
+
accounts: string[];
|
|
23
|
+
}>;
|
|
24
|
+
private effectiveAccounts;
|
|
25
|
+
save(value: EmailSettingsValue, expectedRevision: number): Promise<{
|
|
26
|
+
settings: {
|
|
27
|
+
value: EmailSettingsValue;
|
|
28
|
+
revision: any;
|
|
29
|
+
applies: any;
|
|
30
|
+
};
|
|
31
|
+
writable: boolean;
|
|
32
|
+
accounts: string[];
|
|
33
|
+
}>;
|
|
34
|
+
test(value: EmailSettingsValue): Promise<{
|
|
35
|
+
ok: boolean;
|
|
36
|
+
ms: number;
|
|
37
|
+
}>;
|
|
38
|
+
responseJson(res: any, status: number, body: unknown): void;
|
|
39
|
+
handle(req: any, res: any): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/** Mount the same-origin route when a webServer service is present. */
|
|
42
|
+
export declare function installEmailSettingsWeb(ctx: any, backend: EmailSettingsBackend): void;
|
package/lib/web.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { SETTINGS_NAMESPACE, toEmailConfig, validateSettingsValue } from './settings.js';
|
|
2
|
+
import { resolveEmailSettings } from './config.js';
|
|
3
|
+
import { EmailPool } from './mail-client.js';
|
|
4
|
+
/** Same-origin route the browser settings section talks to. */
|
|
5
|
+
export const SETTINGS_ROUTE = '/_dsh/dsh-email/settings';
|
|
6
|
+
function messageOf2(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Browser-facing backend: snapshot the settings namespace, save it with
|
|
11
|
+
* optimistic concurrency, and test a draft account over a live IMAP login.
|
|
12
|
+
*/
|
|
13
|
+
export class EmailSettingsBackend {
|
|
14
|
+
ctx;
|
|
15
|
+
scope;
|
|
16
|
+
rowConfig;
|
|
17
|
+
constructor(ctx, scope, rowConfig) {
|
|
18
|
+
this.ctx = ctx;
|
|
19
|
+
this.scope = scope;
|
|
20
|
+
this.rowConfig = rowConfig;
|
|
21
|
+
}
|
|
22
|
+
effective(value) {
|
|
23
|
+
return { ...this.rowConfig, ...toEmailConfig(value) };
|
|
24
|
+
}
|
|
25
|
+
async snapshot() {
|
|
26
|
+
const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
|
|
27
|
+
const value = this.scope.get();
|
|
28
|
+
return {
|
|
29
|
+
settings: {
|
|
30
|
+
value,
|
|
31
|
+
revision: descriptor?.revision ?? 0,
|
|
32
|
+
applies: descriptor?.applies ?? 'live',
|
|
33
|
+
},
|
|
34
|
+
writable: this.ctx.settings.writable !== false,
|
|
35
|
+
accounts: [...(this.effectiveAccounts().keys())],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
effectiveAccounts() {
|
|
39
|
+
try {
|
|
40
|
+
return resolveEmailSettings(this.effective(this.scope.get())).accounts;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return new Map();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async save(value, expectedRevision) {
|
|
47
|
+
if (this.ctx.settings.writable === false)
|
|
48
|
+
throw new Error('settings provider is read-only');
|
|
49
|
+
validateSettingsValue(value);
|
|
50
|
+
await this.ctx.settings.replace(SETTINGS_NAMESPACE, value, expectedRevision);
|
|
51
|
+
return this.snapshot();
|
|
52
|
+
}
|
|
53
|
+
async test(value) {
|
|
54
|
+
validateSettingsValue(value);
|
|
55
|
+
const settings = resolveEmailSettings(this.effective(value));
|
|
56
|
+
const pool = new EmailPool(settings);
|
|
57
|
+
try {
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
await pool.withImap(settings.defaultAccount, null, async () => 'connected');
|
|
60
|
+
return { ok: true, ms: Date.now() - started };
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
pool.dispose();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
responseJson(res, status, body) {
|
|
67
|
+
const bytes = Buffer.from(JSON.stringify(body));
|
|
68
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
69
|
+
res.setHeader('Content-Length', String(bytes.length));
|
|
70
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
71
|
+
res.writeHead(status);
|
|
72
|
+
res.end(bytes);
|
|
73
|
+
}
|
|
74
|
+
async handle(req, res) {
|
|
75
|
+
if (req.method === 'GET') {
|
|
76
|
+
try {
|
|
77
|
+
this.responseJson(res, 200, { ok: true, value: await this.snapshot() });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
this.responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: messageOf2(error) } });
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (req.method !== 'POST') {
|
|
85
|
+
res.setHeader('Allow', 'GET, POST');
|
|
86
|
+
this.responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET or POST' } });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let body;
|
|
90
|
+
try {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
for await (const chunk of req) {
|
|
93
|
+
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
94
|
+
if (chunks.reduce((n, c) => n + c.length, 0) + part.length > 256 * 1024)
|
|
95
|
+
throw new RangeError('request body too large');
|
|
96
|
+
chunks.push(part);
|
|
97
|
+
}
|
|
98
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.responseJson(res, 400, { ok: false, error: { code: 'invalid-request', message: messageOf2(error) } });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
if (body?.action === 'save') {
|
|
106
|
+
if (!Number.isSafeInteger(body.expectedRevision))
|
|
107
|
+
throw new Error('expectedRevision must be a non-negative integer');
|
|
108
|
+
this.responseJson(res, 200, { ok: true, value: await this.save(body.value, body.expectedRevision) });
|
|
109
|
+
}
|
|
110
|
+
else if (body?.action === 'test') {
|
|
111
|
+
this.responseJson(res, 200, { ok: true, value: await this.test(body.value) });
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
this.responseJson(res, 400, { ok: false, error: { code: 'invalid-request', message: 'unsupported action' } });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const conflict = error?.code === 'SETTINGS_CONFLICT';
|
|
119
|
+
this.responseJson(res, conflict ? 409 : 400, {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: { code: conflict ? 'settings-conflict' : 'rejected', message: messageOf2(error) },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Mount the same-origin route when a webServer service is present. */
|
|
127
|
+
export function installEmailSettingsWeb(ctx, backend) {
|
|
128
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
129
|
+
webCtx.effect(() => {
|
|
130
|
+
const dispose = webCtx.webServer.register({
|
|
131
|
+
kind: 'exact',
|
|
132
|
+
path: SETTINGS_ROUTE,
|
|
133
|
+
handler: (req, res) => backend.handle(req, res),
|
|
134
|
+
});
|
|
135
|
+
return () => dispose();
|
|
136
|
+
}, 'dsh-email: web route');
|
|
137
|
+
});
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,62 +1,66 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "IMAP/SMTP email tools for DeepSeek Harness: list, read, search and send mail, with QQ/163/126/Sina/Aliyun/Gmail/Outlook/iCloud presets.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "lib/index.js",
|
|
7
|
-
"types": "lib/index.d.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
"types": "./lib/index.d.ts",
|
|
11
|
-
"default": "./lib/index.js"
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"url": "https://github.com/STARDUSTLC666/dsh-email
|
|
40
|
-
},
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-email",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "IMAP/SMTP email tools for DeepSeek Harness: list, read, search and send mail, with QQ/163/126/Sina/Aliyun/Gmail/Outlook/iCloud presets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": "./lib/client.js",
|
|
14
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"prepare": "tsc",
|
|
25
|
+
"prepublishOnly": "pnpm run build",
|
|
26
|
+
"test": "pnpm run build && node --test \"test/*.test.mjs\""
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"dsh-plugin",
|
|
30
|
+
"deepseek-harness",
|
|
31
|
+
"dsh",
|
|
32
|
+
"email",
|
|
33
|
+
"imap",
|
|
34
|
+
"smtp"
|
|
35
|
+
],
|
|
36
|
+
"author": "stardustlc",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/STARDUSTLC666/dsh-email"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/STARDUSTLC666/dsh-email/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/STARDUSTLC666/dsh-email#readme",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"packageManager": "pnpm@11.7.0",
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"dsh": {
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"schemastery": "^3.18.0",
|
|
57
|
+
"imapflow": "^1.7.0",
|
|
58
|
+
"mailparser": "^3.9.15",
|
|
59
|
+
"nodemailer": "^9.0.5"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^26.2.0",
|
|
63
|
+
"@types/nodemailer": "^8.0.1",
|
|
64
|
+
"typescript": "^5.7.3"
|
|
65
|
+
}
|
|
66
|
+
}
|