dsh-email 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -11
- package/lib/client.js +295 -0
- package/lib/config.d.ts +28 -8
- package/lib/config.js +80 -22
- package/lib/index.d.ts +3 -3
- package/lib/index.js +219 -74
- package/lib/mail-client.d.ts +36 -13
- package/lib/mail-client.js +286 -72
- package/lib/parse.d.ts +5 -0
- package/lib/parse.js +25 -6
- package/lib/settings.d.ts +91 -0
- package/lib/settings.js +92 -0
- package/lib/types.d.ts +51 -9
- package/lib/web.d.ts +42 -0
- package/lib/web.js +138 -0
- package/package.json +15 -2
package/lib/index.js
CHANGED
|
@@ -1,43 +1,70 @@
|
|
|
1
|
-
import { clampInt,
|
|
2
|
-
import {
|
|
1
|
+
import { clampInt, resolveEmailSettings } from './config.js';
|
|
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
|
+
}
|
|
36
|
+
const strArray = { type: 'array', items: { type: 'string' } };
|
|
37
|
+
const addrArray = { type: 'array', items: { type: 'object', additionalProperties: true } };
|
|
38
|
+
const messageShape = {
|
|
39
|
+
uid: { type: 'integer' },
|
|
40
|
+
date: { type: 'string' },
|
|
41
|
+
from: addrArray,
|
|
42
|
+
subject: { type: 'string' },
|
|
43
|
+
seen: { type: 'boolean' },
|
|
44
|
+
flagged: { type: 'boolean' },
|
|
45
|
+
size: { type: 'integer' },
|
|
46
|
+
hasAttachments: { type: 'boolean' },
|
|
47
|
+
};
|
|
7
48
|
const listSchema = {
|
|
8
49
|
type: 'object',
|
|
9
50
|
properties: {
|
|
51
|
+
account: { type: 'string' },
|
|
10
52
|
count: { type: 'integer' },
|
|
11
53
|
folder: { type: 'string' },
|
|
12
|
-
messages: {
|
|
13
|
-
type: 'array',
|
|
14
|
-
items: {
|
|
15
|
-
type: 'object',
|
|
16
|
-
properties: {
|
|
17
|
-
uid: { type: 'integer' },
|
|
18
|
-
date: { type: 'string' },
|
|
19
|
-
from: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
20
|
-
subject: { type: 'string' },
|
|
21
|
-
seen: { type: 'boolean' },
|
|
22
|
-
flagged: { type: 'boolean' },
|
|
23
|
-
size: { type: 'integer' },
|
|
24
|
-
hasAttachments: { type: 'boolean' },
|
|
25
|
-
},
|
|
26
|
-
additionalProperties: true,
|
|
27
|
-
},
|
|
28
|
-
},
|
|
54
|
+
messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
|
|
29
55
|
},
|
|
30
56
|
additionalProperties: true,
|
|
31
57
|
};
|
|
32
58
|
const readSchema = {
|
|
33
59
|
type: 'object',
|
|
34
60
|
properties: {
|
|
61
|
+
account: { type: 'string' },
|
|
35
62
|
uid: { type: 'integer' },
|
|
36
63
|
folder: { type: 'string' },
|
|
37
64
|
date: { type: 'string' },
|
|
38
|
-
from:
|
|
39
|
-
to:
|
|
40
|
-
cc:
|
|
65
|
+
from: addrArray,
|
|
66
|
+
to: addrArray,
|
|
67
|
+
cc: addrArray,
|
|
41
68
|
subject: { type: 'string' },
|
|
42
69
|
text: { type: 'string' },
|
|
43
70
|
attachments: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
@@ -48,13 +75,46 @@ const readSchema = {
|
|
|
48
75
|
const sendSchema = {
|
|
49
76
|
type: 'object',
|
|
50
77
|
properties: {
|
|
78
|
+
account: { type: 'string' },
|
|
51
79
|
messageId: { type: 'string' },
|
|
52
|
-
accepted:
|
|
53
|
-
rejected:
|
|
80
|
+
accepted: strArray,
|
|
81
|
+
rejected: strArray,
|
|
54
82
|
response: { type: 'string' },
|
|
55
83
|
},
|
|
56
84
|
additionalProperties: true,
|
|
57
85
|
};
|
|
86
|
+
const foldersSchema = {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
account: { type: 'string' },
|
|
90
|
+
folders: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
items: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
name: { type: 'string' },
|
|
96
|
+
path: { type: 'string' },
|
|
97
|
+
specialUse: { type: 'string' },
|
|
98
|
+
subscribed: { type: 'boolean' },
|
|
99
|
+
},
|
|
100
|
+
additionalProperties: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
additionalProperties: true,
|
|
105
|
+
};
|
|
106
|
+
const attachmentSchema = {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
account: { type: 'string' },
|
|
110
|
+
uid: { type: 'integer' },
|
|
111
|
+
filename: { type: 'string' },
|
|
112
|
+
contentType: { type: 'string' },
|
|
113
|
+
size: { type: 'integer' },
|
|
114
|
+
path: { type: 'string' },
|
|
115
|
+
},
|
|
116
|
+
additionalProperties: true,
|
|
117
|
+
};
|
|
58
118
|
function oneText(text) {
|
|
59
119
|
return [{ type: 'text', text }];
|
|
60
120
|
}
|
|
@@ -65,152 +125,237 @@ function describeMessage(message) {
|
|
|
65
125
|
message.flagged ? '已标星' : '',
|
|
66
126
|
message.hasAttachments ? '含附件' : '',
|
|
67
127
|
].filter(Boolean);
|
|
68
|
-
const parts = [
|
|
128
|
+
const parts = ['uid=' + message.uid, from, message.date];
|
|
69
129
|
if (flags.length > 0)
|
|
70
130
|
parts.push(flags.join('、'));
|
|
71
|
-
return
|
|
131
|
+
return (message.subject || '(无主题)') + ' [' + parts.join(' | ') + ']';
|
|
72
132
|
}
|
|
73
133
|
function renderList(value) {
|
|
74
134
|
if (value.messages.length === 0) {
|
|
75
|
-
return oneText(
|
|
135
|
+
return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,本次没有要列出的邮件。');
|
|
76
136
|
}
|
|
77
|
-
const lines = value.messages.map((m, i) =>
|
|
78
|
-
return oneText(
|
|
137
|
+
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
138
|
+
return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,最新 ' + value.messages.length + ' 封:\n\n' + lines.join('\n') + '\n\n用 email_read 配合 uid 阅读全文。');
|
|
79
139
|
}
|
|
80
140
|
function renderRead(value) {
|
|
81
141
|
const from = value.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知)';
|
|
82
142
|
const attach = value.attachments.length > 0
|
|
83
|
-
?
|
|
143
|
+
? '\n附件:' + value.attachments.map((a, i) => '#' + i + ' ' + a.filename + '(' + a.contentType + ',' + a.size + ' 字节)').join(';') + '\n(用 email_attachment 配合 uid 与序号下载)'
|
|
84
144
|
: '';
|
|
85
|
-
return oneText(
|
|
145
|
+
return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
|
|
86
146
|
}
|
|
87
147
|
function renderSearch(value) {
|
|
88
148
|
if (value.messages.length === 0) {
|
|
89
|
-
return oneText(
|
|
149
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
|
|
90
150
|
}
|
|
91
|
-
const lines = value.messages.map((m, i) =>
|
|
92
|
-
return oneText(
|
|
151
|
+
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
152
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,展示最新 ' + value.messages.length + ' 条:\n\n' + lines.join('\n'));
|
|
93
153
|
}
|
|
94
154
|
function renderSend(value) {
|
|
95
|
-
const rejected = value.rejected.length > 0 ?
|
|
96
|
-
return oneText(
|
|
155
|
+
const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
|
|
156
|
+
return oneText('账号 ' + value.account + ' 邮件已发送,messageId: ' + value.messageId + ';成功送达:' + value.accepted.join(', ') + rejected + ';服务器响应:' + value.response);
|
|
157
|
+
}
|
|
158
|
+
function renderFolders(value) {
|
|
159
|
+
if (value.folders.length === 0)
|
|
160
|
+
return oneText('账号 ' + value.account + ':未列出任何文件夹。');
|
|
161
|
+
const lines = value.folders.map((f, i) => '#' + (i + 1) + ' ' + f.path + (f.specialUse !== '' ? ' [' + f.specialUse + ']' : '') + (f.subscribed ? '' : '(未订阅)'));
|
|
162
|
+
return oneText('账号 ' + value.account + ' 的文件夹(' + value.folders.length + ' 个):\n\n' + lines.join('\n') + '\n\n把 folder 参数填成其中的 path 即可。');
|
|
163
|
+
}
|
|
164
|
+
function renderAttachment(value) {
|
|
165
|
+
return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
|
|
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
|
+
});
|
|
97
177
|
}
|
|
98
178
|
export function apply(ctx, config = {}) {
|
|
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
|
+
});
|
|
187
|
+
let pool = null;
|
|
188
|
+
let poolFingerprint = '';
|
|
189
|
+
const getPool = () => {
|
|
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);
|
|
196
|
+
pool.startIdleSweep();
|
|
197
|
+
poolFingerprint = fp;
|
|
198
|
+
}
|
|
199
|
+
return pool;
|
|
200
|
+
};
|
|
99
201
|
// Load-time nudge only: never break boot, tools report the details instead.
|
|
100
202
|
try {
|
|
101
|
-
|
|
203
|
+
getPool();
|
|
102
204
|
}
|
|
103
205
|
catch (error) {
|
|
104
|
-
ctx.logger?.warn?.(
|
|
206
|
+
ctx.logger?.warn?.('[dsh-email] ' + messageOf(error, '未配置邮箱账号'));
|
|
105
207
|
}
|
|
106
|
-
|
|
208
|
+
ctx.effect(() => () => {
|
|
209
|
+
pool?.dispose();
|
|
210
|
+
pool = null;
|
|
211
|
+
});
|
|
212
|
+
installEmailSettingsWeb(ctx, new EmailSettingsBackend(ctx, settingsScope, config));
|
|
107
213
|
ctx.tools.register({
|
|
108
214
|
name: 'email_list',
|
|
109
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.',
|
|
110
|
-
parameters: {
|
|
111
|
-
folder: { type: 'string', description:
|
|
216
|
+
parameters: compileParameters({
|
|
217
|
+
folder: { type: 'string', description: 'IMAP folder path (see email_folders); defaults to the account inboxFolder' },
|
|
112
218
|
limit: { type: 'integer', description: 'How many messages to return, 1-100, default 20' },
|
|
113
219
|
offset: { type: 'integer', description: 'Skip this many newest messages first, default 0' },
|
|
114
220
|
unreadOnly: { type: 'boolean', description: 'Only list unread messages, default false' },
|
|
115
|
-
|
|
221
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
222
|
+
}),
|
|
116
223
|
output: {
|
|
117
224
|
schema: listSchema,
|
|
118
225
|
render: (_args, value) => renderList(value),
|
|
119
226
|
},
|
|
120
227
|
async execute(rawArgs) {
|
|
121
228
|
const args = rawArgs;
|
|
122
|
-
const cfg = resolve();
|
|
123
229
|
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
|
124
230
|
const offset = clampInt(args.offset, 0, 0, 10000);
|
|
125
|
-
return await
|
|
231
|
+
return await getPool().list(args.account, args.folder?.trim() || '', limit, offset, args.unreadOnly === true);
|
|
126
232
|
},
|
|
127
233
|
});
|
|
128
234
|
ctx.tools.register({
|
|
129
235
|
name: 'email_read',
|
|
130
|
-
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.',
|
|
131
|
-
parameters: {
|
|
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.',
|
|
237
|
+
parameters: compileParameters({
|
|
132
238
|
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
133
|
-
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the
|
|
134
|
-
|
|
239
|
+
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
240
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
241
|
+
}),
|
|
135
242
|
output: {
|
|
136
243
|
schema: readSchema,
|
|
137
244
|
render: (_args, value) => renderRead(value),
|
|
138
245
|
},
|
|
139
246
|
async execute(rawArgs) {
|
|
140
247
|
const args = rawArgs;
|
|
141
|
-
const cfg = resolve();
|
|
142
248
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
143
249
|
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
144
250
|
}
|
|
145
|
-
return await
|
|
251
|
+
return await getPool().read(args.account, args.uid, args.folder?.trim() || '');
|
|
146
252
|
},
|
|
147
253
|
});
|
|
148
254
|
ctx.tools.register({
|
|
149
255
|
name: 'email_search',
|
|
150
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.',
|
|
151
|
-
parameters: {
|
|
257
|
+
parameters: compileParameters({
|
|
152
258
|
query: { type: 'string', required: true, description: 'Keyword to search for' },
|
|
153
|
-
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the
|
|
259
|
+
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
|
|
154
260
|
limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
|
|
155
|
-
|
|
261
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
262
|
+
}),
|
|
156
263
|
output: {
|
|
157
264
|
schema: listSchema,
|
|
158
265
|
render: (_args, value) => renderSearch(value),
|
|
159
266
|
},
|
|
160
267
|
async execute(rawArgs) {
|
|
161
268
|
const args = rawArgs;
|
|
162
|
-
const cfg = resolve();
|
|
163
269
|
if (typeof args.query !== 'string' || args.query.trim() === '')
|
|
164
270
|
throw new Error('query 不能为空');
|
|
165
271
|
const limit = clampInt(args.limit, 10, 1, MAX_LIMIT);
|
|
166
|
-
return await
|
|
272
|
+
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit);
|
|
167
273
|
},
|
|
168
274
|
});
|
|
169
275
|
ctx.tools.register({
|
|
170
276
|
name: 'email_send',
|
|
171
|
-
description: 'Send an email from
|
|
172
|
-
parameters: {
|
|
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.',
|
|
278
|
+
parameters: compileParameters({
|
|
173
279
|
to: { type: 'string', required: true, description: 'Recipient(s), comma-separated' },
|
|
174
280
|
subject: { type: 'string', required: true, description: 'Email subject' },
|
|
175
281
|
text: { type: 'string', description: 'Plain-text body' },
|
|
176
282
|
cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
|
|
177
|
-
|
|
283
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the dsh process cwd)' },
|
|
284
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
285
|
+
}),
|
|
178
286
|
output: {
|
|
179
287
|
schema: sendSchema,
|
|
180
288
|
render: (_args, value) => renderSend(value),
|
|
181
289
|
},
|
|
182
290
|
async execute(rawArgs) {
|
|
183
291
|
const args = rawArgs;
|
|
184
|
-
const cfg = resolve();
|
|
185
292
|
if (typeof args.to !== 'string' || args.to.trim() === '')
|
|
186
293
|
throw new Error('to 不能为空');
|
|
187
294
|
if (typeof args.subject !== 'string' || args.subject.trim() === '')
|
|
188
295
|
throw new Error('subject 不能为空');
|
|
189
|
-
return await
|
|
296
|
+
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, Array.isArray(args.attachments) ? args.attachments : undefined);
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
ctx.tools.register({
|
|
300
|
+
name: 'email_folders',
|
|
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.',
|
|
302
|
+
parameters: compileParameters({
|
|
303
|
+
subscribedOnly: { type: 'boolean', description: 'Only subscribed folders, default false' },
|
|
304
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
305
|
+
}),
|
|
306
|
+
output: {
|
|
307
|
+
schema: foldersSchema,
|
|
308
|
+
render: (_args, value) => renderFolders(value),
|
|
309
|
+
},
|
|
310
|
+
async execute(rawArgs) {
|
|
311
|
+
const args = rawArgs;
|
|
312
|
+
return await getPool().folders(args.account, args.subscribedOnly === true);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
ctx.tools.register({
|
|
316
|
+
name: 'email_attachment',
|
|
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.',
|
|
318
|
+
parameters: compileParameters({
|
|
319
|
+
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
320
|
+
index: { type: 'integer', description: '0-based attachment index, as listed by email_read; default 0' },
|
|
321
|
+
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
322
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
323
|
+
}),
|
|
324
|
+
output: {
|
|
325
|
+
schema: attachmentSchema,
|
|
326
|
+
render: (_args, value) => renderAttachment(value),
|
|
327
|
+
},
|
|
328
|
+
async execute(rawArgs) {
|
|
329
|
+
const args = rawArgs;
|
|
330
|
+
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
331
|
+
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
332
|
+
}
|
|
333
|
+
const index = clampInt(args.index, 0, 0, 999);
|
|
334
|
+
return await getPool().downloadAttachment(args.account, args.folder?.trim() || '', args.uid, index);
|
|
190
335
|
},
|
|
191
336
|
});
|
|
192
337
|
// Approval gate: the user must confirm every send (recipient + subject).
|
|
193
|
-
// Runs before other listeners; degrades to
|
|
194
|
-
//
|
|
338
|
+
// Runs before other listeners; degrades to the tool-time failure only
|
|
339
|
+
// when the account itself is not configured yet.
|
|
195
340
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
196
341
|
if (exec?.name !== 'email_send')
|
|
197
342
|
return next();
|
|
198
|
-
|
|
343
|
+
const value = settingsScope.get();
|
|
344
|
+
if (value.sendApproval === false)
|
|
199
345
|
return next();
|
|
200
346
|
try {
|
|
201
|
-
|
|
347
|
+
resolveEmailSettings({ ...config, ...toEmailConfig(value) });
|
|
202
348
|
}
|
|
203
349
|
catch {
|
|
204
|
-
return next();
|
|
350
|
+
return next(); // unconfigured: let the tool report the actionable hint
|
|
205
351
|
}
|
|
206
352
|
const args = (exec.args ?? {});
|
|
207
|
-
|
|
353
|
+
const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
|
|
354
|
+
const reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
|
|
355
|
+
return { kind: 'ask', reason };
|
|
208
356
|
}, { prepend: true });
|
|
209
357
|
}
|
|
210
|
-
function resolveSafeFolder(config) {
|
|
211
|
-
return config.inboxFolder?.trim() || 'INBOX';
|
|
212
|
-
}
|
|
213
358
|
export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
|
|
214
|
-
export { resolveEmailConfig, clampInt } from './config.js';
|
|
215
|
-
export { stripHtml, truncateText, flattenAddresses, parseRawMessage } from './parse.js';
|
|
216
|
-
export {
|
|
359
|
+
export { resolveEmailConfig, resolveEmailSettings, clampInt, defaultDownloadDir } from './config.js';
|
|
360
|
+
export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
|
|
361
|
+
export { EmailPool, MailError, messageOf, validateAttachmentPaths } from './mail-client.js';
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -1,20 +1,43 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
1
|
+
import { ImapFlow } from 'imapflow';
|
|
2
|
+
import type { ResolvedEmailConfig, ResolvedEmailSettings } from './config.js';
|
|
3
|
+
import type { EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailReadResult, EmailSearchResult, EmailSendResult } from './types.js';
|
|
3
4
|
export declare class MailError extends Error {
|
|
4
5
|
constructor(message: string);
|
|
5
6
|
}
|
|
6
7
|
export declare function messageOf(error: unknown, fallback: string): string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/**
|
|
9
|
+
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
10
|
+
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
11
|
+
*/
|
|
12
|
+
export declare class EmailPool {
|
|
13
|
+
private readonly settings;
|
|
14
|
+
private readonly imaps;
|
|
15
|
+
private readonly smtps;
|
|
16
|
+
private readonly queues;
|
|
17
|
+
private idleTimer;
|
|
18
|
+
constructor(settings: ResolvedEmailSettings);
|
|
19
|
+
account(name: string): ResolvedEmailConfig;
|
|
20
|
+
resolveName(name?: string): string;
|
|
21
|
+
/** Serialize operations per account: one IMAP connection serves one op at a time. */
|
|
22
|
+
private enqueue;
|
|
23
|
+
withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>): Promise<T>;
|
|
11
24
|
private createImap;
|
|
12
|
-
|
|
13
|
-
private
|
|
14
|
-
private
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
private imapRun;
|
|
26
|
+
private normalizeImapError;
|
|
27
|
+
private evictImap;
|
|
28
|
+
/** Reap IMAP connections idle for longer than idleTimeoutMs. */
|
|
29
|
+
startIdleSweep(): void;
|
|
30
|
+
dispose(): void;
|
|
31
|
+
private transporter;
|
|
32
|
+
list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean): Promise<EmailListResult>;
|
|
33
|
+
search(accountName: string | undefined, query: string, folder: string, limit: number): Promise<EmailSearchResult>;
|
|
17
34
|
private fetchListed;
|
|
18
|
-
read(uid: number, folder: string): Promise<EmailReadResult>;
|
|
19
|
-
|
|
35
|
+
read(accountName: string | undefined, uid: number, folder: string): Promise<EmailReadResult>;
|
|
36
|
+
folders(accountName: string | undefined, subscribedOnly: boolean): Promise<EmailFoldersResult>;
|
|
37
|
+
downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number): Promise<EmailAttachmentResult>;
|
|
38
|
+
send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined): Promise<EmailSendResult>;
|
|
20
39
|
}
|
|
40
|
+
/** Stat every attachment path up front; total size must stay under the cap. */
|
|
41
|
+
export declare function validateAttachmentPaths(paths: string[], maxBytes: number): Promise<Array<{
|
|
42
|
+
path: string;
|
|
43
|
+
}>>;
|