dsh-email 0.2.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
@@ -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(配置/解析/注册与审批门,27 个用例,无需真实邮箱)
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/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/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
- let settingsError = '';
140
- let settings = null;
141
- try {
142
- settings = resolveEmailSettings(config);
143
- }
144
- catch (error) {
145
- settingsError = messageOf(error, 'dsh-email 配置无效');
146
- ctx.logger?.warn?.('[dsh-email] ' + settingsError);
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
- if (settingsError !== '')
151
- throw new Error(settingsError);
152
- if (pool === null) {
153
- pool = new EmailPool(settings);
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
- if (config.sendApproval === false)
293
- return next();
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;
@@ -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.2.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
- "files": [
15
- "lib",
16
- "cordis.patch.yml",
17
- "README.md"
18
- ],
19
- "scripts": {
20
- "build": "tsc",
21
- "prepare": "tsc",
22
- "prepublishOnly": "pnpm run build",
23
- "test": "pnpm run build && node --test \"test/*.test.mjs\""
24
- },
25
- "keywords": [
26
- "dsh-plugin",
27
- "deepseek-harness",
28
- "dsh",
29
- "email",
30
- "imap",
31
- "smtp"
32
- ],
33
- "author": "stardustlc",
34
- "repository": {
35
- "type": "git",
36
- "url": "https://github.com/STARDUSTLC666/dsh-email"
37
- },
38
- "bugs": {
39
- "url": "https://github.com/STARDUSTLC666/dsh-email/issues"
40
- },
41
- "homepage": "https://github.com/STARDUSTLC666/dsh-email#readme",
42
- "license": "MIT",
43
- "packageManager": "pnpm@11.7.0",
44
- "engines": {
45
- "node": ">=20"
46
- },
47
- "dsh": {
48
- "bundle": {
49
- "patch": "./cordis.patch.yml"
50
- }
51
- },
52
- "dependencies": {
53
- "imapflow": "^1.7.0",
54
- "mailparser": "^3.9.15",
55
- "nodemailer": "^9.0.5"
56
- },
57
- "devDependencies": {
58
- "@types/node": "^26.2.0",
59
- "@types/nodemailer": "^8.0.1",
60
- "typescript": "^5.7.3"
61
- }
62
- }
1
+ {
2
+ "name": "dsh-email",
3
+ "version": "0.3.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
+ "./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
+ }