dsh-email 0.10.2 → 0.10.4
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.en.md +11 -3
- package/README.md +11 -5
- package/lib/approval.d.ts +34 -0
- package/lib/approval.js +55 -0
- package/lib/index.d.ts +8 -12
- package/lib/index.js +17 -669
- package/lib/mail-client.d.ts +12 -10
- package/lib/mail-client.js +141 -52
- package/lib/runtime.d.ts +36 -0
- package/lib/runtime.js +92 -0
- package/lib/tool-contract.d.ts +423 -0
- package/lib/tool-contract.js +353 -0
- package/lib/tools.d.ts +13 -0
- package/lib/tools.js +191 -0
- package/package.json +5 -4
package/lib/index.js
CHANGED
|
@@ -1,674 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { installSendApproval } from './approval.js';
|
|
2
|
+
import { createEmailRuntime } from './runtime.js';
|
|
3
|
+
import { buildEmailTools } from './tools.js';
|
|
4
4
|
import { EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
|
5
5
|
export const name = 'tool-email';
|
|
6
6
|
export const inject = ['settings', 'tools'];
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 解析天级日期参数为 Date。接受 YYYY-MM-DD 或完整 ISO 时间。
|
|
10
|
-
* endInclusive=true 时返回“该日结束”(次日零点),用于 until 语义(IMAP BEFORE 是不含当天的)。
|
|
11
|
-
*/
|
|
12
|
-
export function parseEmailDay(input, label, endInclusive = false) {
|
|
13
|
-
const text = input.trim();
|
|
14
|
-
let date = null;
|
|
15
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
|
16
|
-
date = new Date(text + 'T00:00:00Z');
|
|
17
|
-
}
|
|
18
|
-
else {
|
|
19
|
-
const parsed = new Date(text);
|
|
20
|
-
if (!Number.isNaN(parsed.getTime()))
|
|
21
|
-
date = parsed;
|
|
22
|
-
}
|
|
23
|
-
if (date === null || Number.isNaN(date.getTime())) {
|
|
24
|
-
throw new Error(label + ' 不是有效日期,请给形如 2026-08-01 或 2026-08-01T00:00:00Z 的值');
|
|
25
|
-
}
|
|
26
|
-
if (endInclusive)
|
|
27
|
-
return new Date(date.getTime() + 24 * 3600 * 1000);
|
|
28
|
-
return date;
|
|
29
|
-
}
|
|
30
|
-
const ACCOUNT_HINT = '账号名(配置了 accounts 多个账号时选择),省略时用 defaultAccount。可用账号见 email_folders 的报错或插件 README';
|
|
31
|
-
/**
|
|
32
|
-
* Compile the author DSL map into a raw JSON Schema object, exactly what
|
|
33
|
-
* defineTool stores as definition.parameters. The native wire request sends
|
|
34
|
-
* this value verbatim, so a raw DSL here would be rejected by the model API
|
|
35
|
-
* ("schema must be a JSON Schema of 'type: object'").
|
|
36
|
-
*/
|
|
37
|
-
function compileParameters(spec) {
|
|
38
|
-
const properties = {};
|
|
39
|
-
const required = [];
|
|
40
|
-
for (const [key, prop] of Object.entries(spec)) {
|
|
41
|
-
if (prop?.required === true)
|
|
42
|
-
required.push(key);
|
|
43
|
-
const node = {};
|
|
44
|
-
if (typeof prop?.type === 'string')
|
|
45
|
-
node.type = prop.type;
|
|
46
|
-
if (typeof prop?.description === 'string')
|
|
47
|
-
node.description = prop.description;
|
|
48
|
-
if (prop?.type === 'array' && prop.items !== null && typeof prop.items === 'object') {
|
|
49
|
-
const items = { type: 'string' };
|
|
50
|
-
if (prop.items.type === 'object')
|
|
51
|
-
items.additionalProperties = true;
|
|
52
|
-
node.items = items;
|
|
53
|
-
}
|
|
54
|
-
properties[key] = node;
|
|
55
|
-
}
|
|
56
|
-
return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
|
|
57
|
-
}
|
|
58
|
-
const strArray = { type: 'array', items: { type: 'string' } };
|
|
59
|
-
const addrArray = { type: 'array', items: { type: 'object', additionalProperties: true } };
|
|
60
|
-
const messageShape = {
|
|
61
|
-
uid: { type: 'integer' },
|
|
62
|
-
date: { type: 'string' },
|
|
63
|
-
from: addrArray,
|
|
64
|
-
subject: { type: 'string' },
|
|
65
|
-
seen: { type: 'boolean' },
|
|
66
|
-
flagged: { type: 'boolean' },
|
|
67
|
-
size: { type: 'integer' },
|
|
68
|
-
hasAttachments: { type: 'boolean' },
|
|
69
|
-
};
|
|
70
|
-
const listSchema = {
|
|
71
|
-
type: 'object',
|
|
72
|
-
properties: {
|
|
73
|
-
account: { type: 'string' },
|
|
74
|
-
count: { type: 'integer' },
|
|
75
|
-
folder: { type: 'string' },
|
|
76
|
-
messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
|
|
77
|
-
},
|
|
78
|
-
additionalProperties: true,
|
|
79
|
-
};
|
|
80
|
-
const readSchema = {
|
|
81
|
-
type: 'object',
|
|
82
|
-
properties: {
|
|
83
|
-
account: { type: 'string' },
|
|
84
|
-
uid: { type: 'integer' },
|
|
85
|
-
folder: { type: 'string' },
|
|
86
|
-
date: { type: 'string' },
|
|
87
|
-
from: addrArray,
|
|
88
|
-
to: addrArray,
|
|
89
|
-
cc: addrArray,
|
|
90
|
-
subject: { type: 'string' },
|
|
91
|
-
text: { type: 'string' },
|
|
92
|
-
attachments: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
93
|
-
truncated: { type: 'boolean' },
|
|
94
|
-
},
|
|
95
|
-
additionalProperties: true,
|
|
96
|
-
};
|
|
97
|
-
const sendSchema = {
|
|
98
|
-
type: 'object',
|
|
99
|
-
properties: {
|
|
100
|
-
account: { type: 'string' },
|
|
101
|
-
messageId: { type: 'string' },
|
|
102
|
-
accepted: strArray,
|
|
103
|
-
rejected: strArray,
|
|
104
|
-
response: { type: 'string' },
|
|
105
|
-
},
|
|
106
|
-
additionalProperties: true,
|
|
107
|
-
};
|
|
108
|
-
const foldersSchema = {
|
|
109
|
-
type: 'object',
|
|
110
|
-
properties: {
|
|
111
|
-
account: { type: 'string' },
|
|
112
|
-
folders: {
|
|
113
|
-
type: 'array',
|
|
114
|
-
items: {
|
|
115
|
-
type: 'object',
|
|
116
|
-
properties: {
|
|
117
|
-
name: { type: 'string' },
|
|
118
|
-
path: { type: 'string' },
|
|
119
|
-
specialUse: { type: 'string' },
|
|
120
|
-
subscribed: { type: 'boolean' },
|
|
121
|
-
},
|
|
122
|
-
additionalProperties: true,
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
additionalProperties: true,
|
|
127
|
-
};
|
|
128
|
-
const attachmentSchema = {
|
|
129
|
-
type: 'object',
|
|
130
|
-
properties: {
|
|
131
|
-
account: { type: 'string' },
|
|
132
|
-
uid: { type: 'integer' },
|
|
133
|
-
filename: { type: 'string' },
|
|
134
|
-
contentType: { type: 'string' },
|
|
135
|
-
size: { type: 'integer' },
|
|
136
|
-
path: { type: 'string' },
|
|
137
|
-
},
|
|
138
|
-
additionalProperties: true,
|
|
139
|
-
};
|
|
140
|
-
const markSchema = {
|
|
141
|
-
type: 'object',
|
|
142
|
-
properties: {
|
|
143
|
-
account: { type: 'string' },
|
|
144
|
-
uid: { type: 'integer' },
|
|
145
|
-
folder: { type: 'string' },
|
|
146
|
-
action: { type: 'string' },
|
|
147
|
-
seen: { type: 'boolean' },
|
|
148
|
-
flagged: { type: 'boolean' },
|
|
149
|
-
movedTo: { type: 'string' },
|
|
150
|
-
movedUid: { type: 'integer' },
|
|
151
|
-
},
|
|
152
|
-
additionalProperties: true,
|
|
153
|
-
};
|
|
154
|
-
const MARK_ACTIONS = ['read', 'unread', 'star', 'unstar', 'move'];
|
|
155
|
-
const REPLY_MODES = ['reply', 'reply-all', 'forward'];
|
|
156
|
-
function oneText(text) {
|
|
157
|
-
return [{ type: 'text', text }];
|
|
158
|
-
}
|
|
159
|
-
function normalizeAttachmentPaths(value) {
|
|
160
|
-
if (value === undefined || value === null)
|
|
161
|
-
return undefined;
|
|
162
|
-
if (!Array.isArray(value) || value.some(path => typeof path !== 'string' || path.trim() === '')) {
|
|
163
|
-
throw new Error('attachments 必须是文件路径字符串数组(且不能包含空字符串)');
|
|
164
|
-
}
|
|
165
|
-
return value.map(path => path.trim());
|
|
166
|
-
}
|
|
167
|
-
function describeMessage(message) {
|
|
168
|
-
const from = message.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知)';
|
|
169
|
-
const flags = [
|
|
170
|
-
message.seen ? '' : '未读',
|
|
171
|
-
message.flagged ? '已标星' : '',
|
|
172
|
-
message.hasAttachments ? '含附件' : '',
|
|
173
|
-
].filter(Boolean);
|
|
174
|
-
const parts = ['uid=' + message.uid, from, message.date];
|
|
175
|
-
if (flags.length > 0)
|
|
176
|
-
parts.push(flags.join('、'));
|
|
177
|
-
return (message.subject || '(无主题)') + ' [' + parts.join(' | ') + ']';
|
|
178
|
-
}
|
|
179
|
-
function renderList(value) {
|
|
180
|
-
if (value.messages.length === 0) {
|
|
181
|
-
return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,本次没有要列出的邮件。');
|
|
182
|
-
}
|
|
183
|
-
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
184
|
-
return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,最新 ' + value.messages.length + ' 封:\n\n' + lines.join('\n') + '\n\n用 email_read 配合 uid 阅读全文。');
|
|
185
|
-
}
|
|
186
|
-
function renderRead(value) {
|
|
187
|
-
const from = value.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知)';
|
|
188
|
-
const attach = value.attachments.length > 0
|
|
189
|
-
? '\n附件:' + value.attachments.map((a, i) => '#' + i + ' ' + a.filename + '(' + a.contentType + ',' + a.size + ' 字节)').join(';') + '\n(用 email_attachment 配合 uid 与序号下载)'
|
|
190
|
-
: '';
|
|
191
|
-
return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
|
|
192
|
-
}
|
|
193
|
-
function renderSearch(value) {
|
|
194
|
-
if (value.messages.length === 0) {
|
|
195
|
-
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
|
|
196
|
-
}
|
|
197
|
-
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
198
|
-
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,展示最新 ' + value.messages.length + ' 条:\n\n' + lines.join('\n'));
|
|
199
|
-
}
|
|
200
|
-
function renderSend(value) {
|
|
201
|
-
const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
|
|
202
|
-
return oneText('账号 ' + value.account + ' 邮件已发送,messageId: ' + value.messageId + ';成功送达:' + value.accepted.join(', ') + rejected + ';服务器响应:' + value.response);
|
|
203
|
-
}
|
|
204
|
-
function renderFolders(value) {
|
|
205
|
-
if (value.folders.length === 0)
|
|
206
|
-
return oneText('账号 ' + value.account + ':未列出任何文件夹。');
|
|
207
|
-
const lines = value.folders.map((f, i) => '#' + (i + 1) + ' ' + f.path + (f.specialUse !== '' ? ' [' + f.specialUse + ']' : '') + (f.subscribed ? '' : '(未订阅)'));
|
|
208
|
-
return oneText('账号 ' + value.account + ' 的文件夹(' + value.folders.length + ' 个):\n\n' + lines.join('\n') + '\n\n把 folder 参数填成其中的 path 即可。');
|
|
209
|
-
}
|
|
210
|
-
function renderAttachment(value) {
|
|
211
|
-
return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
|
|
212
|
-
}
|
|
213
|
-
const MARK_LABELS = {
|
|
214
|
-
read: '标记为已读',
|
|
215
|
-
unread: '标记为未读',
|
|
216
|
-
star: '加星标',
|
|
217
|
-
unstar: '取消星标',
|
|
218
|
-
move: '移动',
|
|
219
|
-
};
|
|
220
|
-
function renderMark(value) {
|
|
221
|
-
let text = '账号 ' + value.account + ':文件夹 "' + value.folder + '" 中 uid=' + value.uid + ' 已' + (MARK_LABELS[value.action] ?? value.action);
|
|
222
|
-
if (value.action === 'move') {
|
|
223
|
-
text += '到 "' + (value.movedTo ?? '') + '"' + (typeof value.movedUid === 'number' ? '(新 uid=' + value.movedUid + ')' : '');
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
text += '(当前:' + (value.seen ? '已读' : '未读') + (value.flagged ? '、已标星' : '') + ')';
|
|
227
|
-
}
|
|
228
|
-
return oneText(text);
|
|
229
|
-
}
|
|
230
|
-
const REPLY_LABELS = {
|
|
231
|
-
reply: '回复',
|
|
232
|
-
'reply-all': '回复全部',
|
|
233
|
-
forward: '转发',
|
|
234
|
-
};
|
|
235
|
-
function renderReply(value) {
|
|
236
|
-
const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
|
|
237
|
-
return oneText('账号 ' + value.account + ' 已' + REPLY_LABELS[value.mode] + ' uid=' + value.originalUid + ' 的邮件:收件人 ' + value.to.join(', ') + ',主题「' + value.subject + '」,messageId: ' + value.messageId + rejected);
|
|
238
|
-
}
|
|
239
|
-
function fingerprintSettings(settings) {
|
|
240
|
-
return JSON.stringify({
|
|
241
|
-
accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
|
|
242
|
-
defaultAccount: settings.defaultAccount,
|
|
243
|
-
sendApproval: settings.sendApproval,
|
|
244
|
-
maxBodyChars: settings.maxBodyChars,
|
|
245
|
-
downloadDir: settings.downloadDir,
|
|
246
|
-
downloadDirExplicit: settings.downloadDirExplicit,
|
|
247
|
-
maxAttachmentBytes: settings.maxAttachmentBytes,
|
|
248
|
-
bodySearchFallback: settings.bodySearchFallback,
|
|
249
|
-
bodySearchLimit: settings.bodySearchLimit,
|
|
250
|
-
idleTimeoutMs: settings.idleTimeoutMs,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
7
|
+
/** Compose settings/pool lifecycle, tools, browser routes and the outgoing-mail gate. */
|
|
253
8
|
export function apply(ctx, config = {}) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const settingsScope = ctx.settings.register(SETTINGS_NAMESPACE, EmailSettingsSchema, {
|
|
258
|
-
base: toSettingsBase(config),
|
|
259
|
-
applies: 'live',
|
|
260
|
-
validate: (value) => validateSettingsValue(value),
|
|
261
|
-
});
|
|
262
|
-
let pool = null;
|
|
263
|
-
let poolFingerprint = '';
|
|
264
|
-
const getPool = () => {
|
|
265
|
-
// Only fields the user actually set in the settings page override the row
|
|
266
|
-
// config; untouched fields fall back to the row and the provider presets.
|
|
267
|
-
const descriptor = (ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
|
|
268
|
-
const userSection = descriptor?.user;
|
|
269
|
-
const value = settingsScope.get();
|
|
270
|
-
const effective = resolveEmailSettings({ ...config, ...toEmailConfig(value, userSection) });
|
|
271
|
-
const fp = fingerprintSettings(effective);
|
|
272
|
-
if (pool === null || fp !== poolFingerprint) {
|
|
273
|
-
pool?.dispose();
|
|
274
|
-
pool = new EmailPool(effective);
|
|
275
|
-
pool.startIdleSweep();
|
|
276
|
-
poolFingerprint = fp;
|
|
277
|
-
}
|
|
278
|
-
return pool;
|
|
279
|
-
};
|
|
280
|
-
// Load-time nudge only: never break boot, tools report the details instead.
|
|
281
|
-
try {
|
|
282
|
-
getPool();
|
|
283
|
-
}
|
|
284
|
-
catch (error) {
|
|
285
|
-
ctx.logger?.warn?.('[dsh-email] ' + messageOf(error, '未配置邮箱账号'));
|
|
286
|
-
}
|
|
287
|
-
ctx.effect(() => () => {
|
|
288
|
-
pool?.dispose();
|
|
289
|
-
pool = null;
|
|
290
|
-
});
|
|
291
|
-
const backend = new EmailSettingsBackend(ctx, settingsScope, config);
|
|
9
|
+
const runtime = createEmailRuntime(ctx, config);
|
|
10
|
+
const backend = new EmailSettingsBackend(ctx, runtime.settingsScope, config);
|
|
11
|
+
backend.watchImpl = runtime.watch;
|
|
292
12
|
installEmailSettingsWeb(ctx, backend);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
304
|
-
}),
|
|
305
|
-
output: {
|
|
306
|
-
schema: listSchema,
|
|
307
|
-
render: (_args, value) => renderList(value),
|
|
308
|
-
},
|
|
309
|
-
async execute(rawArgs) {
|
|
310
|
-
const args = rawArgs;
|
|
311
|
-
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
|
312
|
-
const offset = clampInt(args.offset, 0, 0, 10000);
|
|
313
|
-
const since = args.since?.trim() ? parseEmailDay(args.since, 'since') : undefined;
|
|
314
|
-
const until = args.until?.trim() ? parseEmailDay(args.until, 'until', true) : undefined;
|
|
315
|
-
return await getPool().list(args.account, args.folder?.trim() || '', limit, offset, args.unreadOnly === true, since, until);
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
ctx.tools.register({
|
|
319
|
-
name: 'email_read',
|
|
320
|
-
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.',
|
|
321
|
-
parameters: compileParameters({
|
|
322
|
-
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
323
|
-
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
324
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
325
|
-
}),
|
|
326
|
-
output: {
|
|
327
|
-
schema: readSchema,
|
|
328
|
-
render: (_args, value) => renderRead(value),
|
|
329
|
-
},
|
|
330
|
-
async execute(rawArgs) {
|
|
331
|
-
const args = rawArgs;
|
|
332
|
-
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
333
|
-
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
334
|
-
}
|
|
335
|
-
return await getPool().read(args.account, args.uid, args.folder?.trim() || '');
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
ctx.tools.register({
|
|
339
|
-
name: 'email_mark',
|
|
340
|
-
description: 'Change an existing message: mark it read/unread, star/unstar it, or move it to another folder. Use after email_list/email_search when the user wants to tidy the mailbox (archive, clear unread, flag important mail). Moving uses the server MOVE/COPY so the uid changes; the new uid is reported when the server provides it.',
|
|
341
|
-
parameters: compileParameters({
|
|
342
|
-
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
343
|
-
action: { type: 'string', required: true, description: 'What to do: read, unread, star, unstar, or move' },
|
|
344
|
-
toFolder: { type: 'string', description: 'Destination folder path for action=move (see email_folders for valid paths)' },
|
|
345
|
-
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
346
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
347
|
-
}),
|
|
348
|
-
output: {
|
|
349
|
-
schema: markSchema,
|
|
350
|
-
render: (_args, value) => renderMark(value),
|
|
351
|
-
},
|
|
352
|
-
async execute(rawArgs) {
|
|
353
|
-
const args = rawArgs;
|
|
354
|
-
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
355
|
-
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
356
|
-
}
|
|
357
|
-
const action = (typeof args.action === 'string' ? args.action.trim().toLowerCase() : '');
|
|
358
|
-
if (!MARK_ACTIONS.includes(action)) {
|
|
359
|
-
throw new Error('action 必须是 ' + MARK_ACTIONS.join('、') + ' 之一');
|
|
360
|
-
}
|
|
361
|
-
if (action === 'move' && (typeof args.toFolder !== 'string' || args.toFolder.trim() === '')) {
|
|
362
|
-
throw new Error('action=move 时需要 toFolder 参数(用 email_folders 查看可用文件夹)');
|
|
363
|
-
}
|
|
364
|
-
return await getPool().mark(args.account, args.folder?.trim() || '', args.uid, action, args.toFolder);
|
|
365
|
-
},
|
|
366
|
-
});
|
|
367
|
-
ctx.tools.register({
|
|
368
|
-
name: 'email_search',
|
|
369
|
-
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.',
|
|
370
|
-
parameters: compileParameters({
|
|
371
|
-
query: { type: 'string', required: true, description: 'Keyword to search for' },
|
|
372
|
-
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
|
|
373
|
-
limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
|
|
374
|
-
since: { type: 'string', description: 'Only search messages received on or after this date, e.g. 2026-08-01 (optional)' },
|
|
375
|
-
until: { type: 'string', description: 'Only search messages received on or before this date, e.g. 2026-08-26 (optional)' },
|
|
376
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
377
|
-
}),
|
|
378
|
-
output: {
|
|
379
|
-
schema: listSchema,
|
|
380
|
-
render: (_args, value) => renderSearch(value),
|
|
381
|
-
},
|
|
382
|
-
async execute(rawArgs) {
|
|
383
|
-
const args = rawArgs;
|
|
384
|
-
if (typeof args.query !== 'string' || args.query.trim() === '')
|
|
385
|
-
throw new Error('query 不能为空');
|
|
386
|
-
const limit = clampInt(args.limit, 10, 1, MAX_LIMIT);
|
|
387
|
-
const since = args.since?.trim() ? parseEmailDay(args.since, 'since') : undefined;
|
|
388
|
-
const until = args.until?.trim() ? parseEmailDay(args.until, 'until', true) : undefined;
|
|
389
|
-
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, since, until);
|
|
390
|
-
},
|
|
391
|
-
});
|
|
392
|
-
ctx.tools.register({
|
|
393
|
-
name: 'email_send',
|
|
394
|
-
description: 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the dsh process cwd). Sending asks the user for approval (recipient, subject and attachment count are shown) unless sendApproval is disabled; in Full Access mode the approval policy never asks, so the send is refused with an explanation instead. Never invent recipients or content without the user\'s instruction.',
|
|
395
|
-
parameters: compileParameters({
|
|
396
|
-
to: { type: 'string', required: true, description: 'Recipient(s), comma-separated' },
|
|
397
|
-
subject: { type: 'string', required: true, description: 'Email subject' },
|
|
398
|
-
text: { type: 'string', description: 'Plain-text body' },
|
|
399
|
-
cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
|
|
400
|
-
attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the dsh process cwd)' },
|
|
401
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
402
|
-
}),
|
|
403
|
-
output: {
|
|
404
|
-
schema: sendSchema,
|
|
405
|
-
render: (_args, value) => renderSend(value),
|
|
406
|
-
},
|
|
407
|
-
async execute(rawArgs) {
|
|
408
|
-
const args = rawArgs;
|
|
409
|
-
if (typeof args.to !== 'string' || args.to.trim() === '')
|
|
410
|
-
throw new Error('to 不能为空');
|
|
411
|
-
if (typeof args.subject !== 'string' || args.subject.trim() === '')
|
|
412
|
-
throw new Error('subject 不能为空');
|
|
413
|
-
return await getPool().send(args.account, args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined, normalizeAttachmentPaths(args.attachments));
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
ctx.tools.register({
|
|
417
|
-
name: 'email_reply',
|
|
418
|
-
description: 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter. Like email_send, this asks the user for approval before sending. Never invent recipients or content without the user\'s instruction.',
|
|
419
|
-
parameters: compileParameters({
|
|
420
|
-
uid: { type: 'integer', required: true, description: 'Message uid to answer/forward, from email_list or email_search' },
|
|
421
|
-
text: { type: 'string', required: true, description: 'The new text to write; the original is quoted below it automatically' },
|
|
422
|
-
mode: { type: 'string', description: 'reply (default), reply-all, or forward' },
|
|
423
|
-
to: { type: 'string', description: 'Recipient(s) for mode=forward, comma-separated' },
|
|
424
|
-
cc: { type: 'string', description: 'Extra CC recipient(s), comma-separated (optional)' },
|
|
425
|
-
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
426
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
427
|
-
}),
|
|
428
|
-
output: {
|
|
429
|
-
schema: {
|
|
430
|
-
type: 'object',
|
|
431
|
-
properties: {
|
|
432
|
-
account: { type: 'string' },
|
|
433
|
-
mode: { type: 'string' },
|
|
434
|
-
originalUid: { type: 'integer' },
|
|
435
|
-
messageId: { type: 'string' },
|
|
436
|
-
accepted: strArray,
|
|
437
|
-
rejected: strArray,
|
|
438
|
-
response: { type: 'string' },
|
|
439
|
-
to: strArray,
|
|
440
|
-
subject: { type: 'string' },
|
|
441
|
-
},
|
|
442
|
-
additionalProperties: true,
|
|
443
|
-
},
|
|
444
|
-
render: (_args, value) => renderReply(value),
|
|
445
|
-
},
|
|
446
|
-
async execute(rawArgs) {
|
|
447
|
-
const args = rawArgs;
|
|
448
|
-
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
449
|
-
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
450
|
-
}
|
|
451
|
-
if (typeof args.text !== 'string' || args.text.trim() === '')
|
|
452
|
-
throw new Error('text 不能为空');
|
|
453
|
-
const mode = ((typeof args.mode === 'string' && args.mode.trim() !== '' ? args.mode.trim().toLowerCase() : 'reply'));
|
|
454
|
-
if (!REPLY_MODES.includes(mode)) {
|
|
455
|
-
throw new Error('mode 必须是 ' + REPLY_MODES.join('、') + ' 之一');
|
|
456
|
-
}
|
|
457
|
-
if (mode === 'forward' && (typeof args.to !== 'string' || args.to.trim() === '')) {
|
|
458
|
-
throw new Error('mode=forward 时需要 to 参数指定转发收件人');
|
|
459
|
-
}
|
|
460
|
-
const cc = typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined;
|
|
461
|
-
return await getPool().reply(args.account, args.folder?.trim() || '', args.uid, mode, args.text, args.to?.trim() ?? '', cc);
|
|
462
|
-
},
|
|
463
|
-
});
|
|
464
|
-
ctx.tools.register({
|
|
465
|
-
name: 'email_folders',
|
|
466
|
-
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.',
|
|
467
|
-
parameters: compileParameters({
|
|
468
|
-
subscribedOnly: { type: 'boolean', description: 'Only subscribed folders, default false' },
|
|
469
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
470
|
-
}),
|
|
471
|
-
output: {
|
|
472
|
-
schema: foldersSchema,
|
|
473
|
-
render: (_args, value) => renderFolders(value),
|
|
474
|
-
},
|
|
475
|
-
async execute(rawArgs) {
|
|
476
|
-
const args = rawArgs;
|
|
477
|
-
return await getPool().folders(args.account, args.subscribedOnly === true);
|
|
478
|
-
},
|
|
479
|
-
});
|
|
480
|
-
ctx.tools.register({
|
|
481
|
-
name: 'email_health',
|
|
482
|
-
description: 'Self-check for dsh-email: summarizes configured accounts (provider / IMAP / SMTP hosts) without any network connection and never shows passwords. Run this first when troubleshooting.',
|
|
483
|
-
parameters: compileParameters({}),
|
|
484
|
-
output: {
|
|
485
|
-
schema: { type: 'object', additionalProperties: true },
|
|
486
|
-
render: (_args, value) => {
|
|
487
|
-
const rec = (value ?? {});
|
|
488
|
-
const rawChecks = Array.isArray(rec.checks) ? rec.checks : [];
|
|
489
|
-
const lines = ['dsh-email 自检' + (rec.ok === true ? ':正常。' : ':发现问题。')];
|
|
490
|
-
for (const item of rawChecks) {
|
|
491
|
-
const c = (item ?? {});
|
|
492
|
-
lines.push('- ' + String(c.name) + ':' + (c.ok === true ? '✅ ' + String(c.detail ?? '') : '❌ ' + String(c.detail ?? '')));
|
|
493
|
-
}
|
|
494
|
-
return [{ type: 'text', text: lines.join('\n') }];
|
|
495
|
-
},
|
|
496
|
-
},
|
|
497
|
-
async execute() {
|
|
498
|
-
const accounts = (config.accounts ?? {});
|
|
499
|
-
const names = Object.keys(accounts);
|
|
500
|
-
const checks = [];
|
|
501
|
-
if (names.length === 0) {
|
|
502
|
-
const topLevelUser = typeof config.user === 'string' && config.user.trim() !== '';
|
|
503
|
-
checks.push({ name: '默认账号', ok: topLevelUser, detail: topLevelUser ? '顶层账号已配置' : '未配置账号:请在 cordis.patch.yml 配置 accounts(或顶层 user/password)' });
|
|
504
|
-
}
|
|
505
|
-
else {
|
|
506
|
-
for (const accountName of names.slice(0, 8)) {
|
|
507
|
-
const account = accounts[accountName] ?? {};
|
|
508
|
-
const hasUser = typeof account.user === 'string' && account.user.trim() !== '';
|
|
509
|
-
const provider = typeof account.provider === 'string' && account.provider !== '' ? account.provider : 'custom';
|
|
510
|
-
checks.push({ name: '账号 ' + accountName, ok: hasUser, detail: hasUser ? provider + ' / ' + account.user : '未配置 user' });
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
const ok = checks.every((c) => c.ok === true);
|
|
514
|
-
return { ok, plugin: 'dsh-email', accountCount: names.length, checks };
|
|
515
|
-
},
|
|
516
|
-
});
|
|
517
|
-
ctx.tools.register({
|
|
518
|
-
name: 'email_attachment',
|
|
519
|
-
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.',
|
|
520
|
-
parameters: compileParameters({
|
|
521
|
-
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
522
|
-
index: { type: 'integer', description: '0-based attachment index, as listed by email_read; default 0' },
|
|
523
|
-
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
524
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
525
|
-
}),
|
|
526
|
-
output: {
|
|
527
|
-
schema: attachmentSchema,
|
|
528
|
-
render: (_args, value) => renderAttachment(value),
|
|
529
|
-
},
|
|
530
|
-
async execute(rawArgs, exec) {
|
|
531
|
-
const args = rawArgs;
|
|
532
|
-
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
533
|
-
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
534
|
-
}
|
|
535
|
-
const index = clampInt(args.index, 0, 0, 999);
|
|
536
|
-
const workspaceHint = typeof exec?.agent?.session?.header?.cwd === 'string' ? exec.agent.session.header.cwd : undefined;
|
|
537
|
-
return await getPool().downloadAttachment(args.account, args.folder?.trim() || '', args.uid, index, workspaceHint);
|
|
538
|
-
},
|
|
539
|
-
});
|
|
540
|
-
// Incremental watch: per scope+account+folder we remember the highest uid
|
|
541
|
-
// already reported. The first call seeds the baseline (firstRun=true,
|
|
542
|
-
// nothing counts as new), later calls report only unseen unread messages.
|
|
543
|
-
// The 'tool' and 'web' scopes keep independent cursors, so the browser
|
|
544
|
-
// popup and the email_watch tool never consume each other's new mail. The
|
|
545
|
-
// same core is exposed to the browser widget via action: watch.
|
|
546
|
-
const watchCursors = new Map();
|
|
547
|
-
const watchCore = async (account, folder, limit, scope) => {
|
|
548
|
-
const capped = clampInt(limit, 20, 1, MAX_LIMIT);
|
|
549
|
-
const result = await getPool().list(account, folder, MAX_LIMIT, 0, true);
|
|
550
|
-
const key = scope + '\u0000' + result.account + '\u0000' + result.folder;
|
|
551
|
-
const isFirst = !watchCursors.has(key);
|
|
552
|
-
const cursor = watchCursors.get(key) ?? 0;
|
|
553
|
-
const fresh = result.messages.filter(m => m.uid > cursor);
|
|
554
|
-
if (result.messages.length > 0) {
|
|
555
|
-
watchCursors.set(key, Math.max(cursor, ...result.messages.map(m => m.uid)));
|
|
556
|
-
}
|
|
557
|
-
else if (isFirst) {
|
|
558
|
-
watchCursors.set(key, 0);
|
|
559
|
-
}
|
|
560
|
-
return {
|
|
561
|
-
account: result.account,
|
|
562
|
-
folder: result.folder,
|
|
563
|
-
firstRun: isFirst,
|
|
564
|
-
newCount: isFirst ? 0 : fresh.length,
|
|
565
|
-
messages: (isFirst ? [] : fresh).slice(0, capped),
|
|
566
|
-
totalUnread: result.count,
|
|
567
|
-
};
|
|
568
|
-
};
|
|
569
|
-
backend.watchImpl = watchCore;
|
|
570
|
-
function renderWatch(value) {
|
|
571
|
-
if (value.firstRun) {
|
|
572
|
-
return oneText('账号 ' + value.account + ':已建立新邮件监视基线(当前未读 ' + value.totalUnread + ' 封)。之后调用 email_watch 只会报告新到的邮件。');
|
|
573
|
-
}
|
|
574
|
-
if (value.newCount === 0) {
|
|
575
|
-
return oneText('账号 ' + value.account + ':没有新邮件(当前未读 ' + value.totalUnread + ' 封)。');
|
|
576
|
-
}
|
|
577
|
-
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
578
|
-
return oneText('账号 ' + value.account + ' 有 ' + value.newCount + ' 封新邮件:\n\n' + lines.join('\n') + '\n\n用 email_read 配合 uid 阅读全文。');
|
|
579
|
-
}
|
|
580
|
-
ctx.tools.register({
|
|
581
|
-
name: 'email_watch',
|
|
582
|
-
description: 'Check for NEW unread emails since the last check (cursor-based). The first call per account+folder sets the baseline and reports nothing as new; every later call returns only unseen unread messages. Call this periodically (e.g. via a scheduled task) to notify the user about new mail. Returns newCount, the new messages, and totalUnread.',
|
|
583
|
-
parameters: compileParameters({
|
|
584
|
-
folder: { type: 'string', description: 'IMAP folder path (see email_folders); defaults to the account inboxFolder' },
|
|
585
|
-
limit: { type: 'integer', description: 'Max number of new messages to return, 1-100, default 20' },
|
|
586
|
-
account: { type: 'string', description: ACCOUNT_HINT },
|
|
587
|
-
}),
|
|
588
|
-
output: {
|
|
589
|
-
schema: {
|
|
590
|
-
type: 'object',
|
|
591
|
-
properties: {
|
|
592
|
-
account: { type: 'string' },
|
|
593
|
-
folder: { type: 'string' },
|
|
594
|
-
firstRun: { type: 'boolean' },
|
|
595
|
-
newCount: { type: 'integer' },
|
|
596
|
-
totalUnread: { type: 'integer' },
|
|
597
|
-
messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
|
|
598
|
-
},
|
|
599
|
-
additionalProperties: true,
|
|
600
|
-
},
|
|
601
|
-
render: (_args, value) => renderWatch(value),
|
|
602
|
-
},
|
|
603
|
-
async execute(rawArgs) {
|
|
604
|
-
const args = rawArgs;
|
|
605
|
-
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
|
606
|
-
return await watchCore(args.account?.trim() || '', args.folder?.trim() || '', limit, 'tool');
|
|
607
|
-
},
|
|
608
|
-
});
|
|
609
|
-
// Approval gate: the user must confirm every outgoing message
|
|
610
|
-
// (email_send: recipient + subject; email_reply: target + subject when
|
|
611
|
-
// known). Runs before other listeners; degrades to the tool-time failure
|
|
612
|
-
// only when the account itself is not configured yet.
|
|
613
|
-
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
614
|
-
if (exec?.name !== 'email_send' && exec?.name !== 'email_reply')
|
|
615
|
-
return next();
|
|
616
|
-
const descriptor = (ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
|
|
617
|
-
const userSection = descriptor?.user;
|
|
618
|
-
const value = settingsScope.get();
|
|
619
|
-
if (value.sendApproval === false)
|
|
620
|
-
return next();
|
|
621
|
-
try {
|
|
622
|
-
resolveEmailSettings({ ...config, ...toEmailConfig(value, userSection) });
|
|
623
|
-
}
|
|
624
|
-
catch {
|
|
625
|
-
return next(); // unconfigured: let the tool report the actionable hint
|
|
626
|
-
}
|
|
627
|
-
let reason;
|
|
628
|
-
if (exec.name === 'email_send') {
|
|
629
|
-
const args = (exec.arguments ?? {});
|
|
630
|
-
const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
|
|
631
|
-
reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
|
|
632
|
-
}
|
|
633
|
-
else {
|
|
634
|
-
const args = (exec.arguments ?? {});
|
|
635
|
-
const mode = typeof args.mode === 'string' && args.mode.trim() !== '' ? args.mode.trim().toLowerCase() : 'reply';
|
|
636
|
-
const modeLabel = mode === 'forward' ? '转发' : mode === 'reply-all' ? '回复全部' : '回复';
|
|
637
|
-
reason = modeLabel + '邮件(原邮件 uid=' + args.uid + ')' + (mode === 'forward' && typeof args.to === 'string' && args.to.trim() !== '' ? ',收件人 ' + args.to : '');
|
|
638
|
-
}
|
|
639
|
-
// Gate-owned approval: we run the approval round-trip ourselves so the
|
|
640
|
-
// denial reason is always honest and actionable — including the Full
|
|
641
|
-
// Access case where the harness policy answers 'rejected' without ever
|
|
642
|
-
// showing a dialog.
|
|
643
|
-
const approval = ctx.get('approval');
|
|
644
|
-
if (approval === undefined) {
|
|
645
|
-
return {
|
|
646
|
-
kind: 'deny',
|
|
647
|
-
reason: 'email_send 需要确认,但当前环境没有审批通道(如 headless)。如确定安全,可在配置中设置 sendApproval: false 后直接发送。',
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
const outcome = await approval.request({
|
|
651
|
-
agent: exec.agent,
|
|
652
|
-
toolName: exec.name,
|
|
653
|
-
callId: exec.callId,
|
|
654
|
-
reason,
|
|
655
|
-
signal: exec.signal,
|
|
656
|
-
});
|
|
657
|
-
if (outcome === 'allowed-once')
|
|
658
|
-
return next();
|
|
659
|
-
if (outcome === 'cancelled')
|
|
660
|
-
return { kind: 'deny', reason: '发信确认被取消,邮件未发送。' };
|
|
661
|
-
if (outcome === 'unavailable')
|
|
662
|
-
return { kind: 'deny', reason: '发信确认不可用(没有可用的审批界面),邮件未发送。' };
|
|
663
|
-
return {
|
|
664
|
-
kind: 'deny',
|
|
665
|
-
reason: '发信未获批准:要么你拒绝了,要么当前会话处于 Full Access(审批策略 never,不会弹框)。若在 Full Access:切到 Read Only / Write 再发,或关闭 sendApproval(自行承担风险)。',
|
|
666
|
-
};
|
|
667
|
-
}, { prepend: true });
|
|
668
|
-
}
|
|
669
|
-
export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
|
|
670
|
-
export { resolveEmailConfig, resolveEmailSettings, parseAccountsYaml, clampInt, defaultDownloadDir } from './config.js';
|
|
671
|
-
export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
|
|
672
|
-
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart, messageMatchesQuery, buildReplyMessage, extractMessageIds } from './mail-client.js';
|
|
673
|
-
export { SETTINGS_NAMESPACE, EmailSettingsSchema, toSettingsBase, toEmailConfig, validateSettingsValue } from './settings.js';
|
|
674
|
-
export { SETTINGS_ROUTE, EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
|
13
|
+
for (const definition of buildEmailTools(runtime))
|
|
14
|
+
ctx.tools.register(definition);
|
|
15
|
+
installSendApproval(ctx, runtime);
|
|
16
|
+
}
|
|
17
|
+
export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, parseAccountsYaml, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings } from './config.js';
|
|
18
|
+
export { buildReplyMessage, EmailPool, extractMessageIds, MailError, messageMatchesQuery, messageOf, selectAttachmentPart, validateAttachmentPaths } from './mail-client.js';
|
|
19
|
+
export { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
|
|
20
|
+
export { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
|
|
21
|
+
export { parseEmailDay } from './tool-contract.js';
|
|
22
|
+
export { EmailSettingsBackend, installEmailSettingsWeb, SETTINGS_ROUTE } from './web.js';
|