dsh-email 0.17.2 → 0.18.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/CHANGELOG.md +13 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/docs/AGENTMAIL.en.md +37 -0
- package/docs/AGENTMAIL.md +41 -0
- package/docs/USAGE.en.md +13 -1
- package/docs/USAGE.md +11 -1
- package/docs/VALIDATION.md +2 -0
- package/docs/validation/0.18.0.md +23 -0
- package/lib/agentmail.d.ts +33 -0
- package/lib/agentmail.js +407 -0
- package/lib/client.js +129 -31
- package/lib/config.d.ts +4 -0
- package/lib/config.js +12 -2
- package/lib/draft-store.d.ts +5 -0
- package/lib/draft-store.js +4 -0
- package/lib/draft-web.js +15 -6
- package/lib/host-config.js +1 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +12 -2
- package/lib/mail-client.d.ts +8 -3
- package/lib/mail-client.js +83 -20
- package/lib/mail-service.d.ts +50 -0
- package/lib/mail-service.js +128 -0
- package/lib/mailbox-web.d.ts +11 -0
- package/lib/mailbox-web.js +219 -0
- package/lib/public-download.d.ts +3 -0
- package/lib/public-download.js +77 -0
- package/lib/runtime.d.ts +2 -2
- package/lib/runtime.js +7 -3
- package/lib/settings.js +1 -1
- package/lib/tool-contract.d.ts +37 -8
- package/lib/tool-contract.js +48 -18
- package/lib/tools.js +14 -10
- package/lib/types.d.ts +44 -12
- package/lib/web.d.ts +9 -1
- package/lib/web.js +20 -2
- package/package.json +4 -3
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { get } from 'node:https';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
export function publicAddress(address) {
|
|
5
|
+
const lower = address.toLowerCase();
|
|
6
|
+
if (isIP(lower) === 4) {
|
|
7
|
+
const [a, b] = lower.split('.').map(Number);
|
|
8
|
+
return !(a === 0 ||
|
|
9
|
+
a === 10 ||
|
|
10
|
+
a === 127 ||
|
|
11
|
+
a >= 224 ||
|
|
12
|
+
(a === 169 && b === 254) ||
|
|
13
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
14
|
+
(a === 192 && (b === 168 || b === 0)) ||
|
|
15
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
16
|
+
(a === 198 && (b === 18 || b === 19)));
|
|
17
|
+
}
|
|
18
|
+
// Global IPv6 unicast only. Mapped/compatible IPv4, loopback, local and multicast are rejected.
|
|
19
|
+
return (isIP(lower) === 6 && /^[23]/.test(lower) && !lower.startsWith('2001:db8:'));
|
|
20
|
+
}
|
|
21
|
+
/** Download a signed public HTTPS URL without API credentials; pin the validated DNS answers. */
|
|
22
|
+
export async function downloadPublicBytes(input, cap, signal, redirects = 0) {
|
|
23
|
+
const url = new URL(input);
|
|
24
|
+
if (url.protocol !== 'https:' ||
|
|
25
|
+
url.username ||
|
|
26
|
+
url.password ||
|
|
27
|
+
(url.port && url.port !== '443') ||
|
|
28
|
+
redirects > 2)
|
|
29
|
+
throw new Error('附件下载地址不受支持');
|
|
30
|
+
const host = url.hostname.replace(/^\[|\]$/g, ''), records = isIP(host)
|
|
31
|
+
? [{ address: host, family: isIP(host) }]
|
|
32
|
+
: await lookup(host, { all: true });
|
|
33
|
+
if (!records.length ||
|
|
34
|
+
records.some((record) => !publicAddress(record.address)))
|
|
35
|
+
throw new Error('附件下载地址必须指向公网 HTTPS 服务');
|
|
36
|
+
const active = AbortSignal.any([
|
|
37
|
+
...(signal ? [signal] : []),
|
|
38
|
+
AbortSignal.timeout(30000),
|
|
39
|
+
]);
|
|
40
|
+
const res = await new Promise((resolve, reject) => {
|
|
41
|
+
const req = get(url, {
|
|
42
|
+
signal: active,
|
|
43
|
+
lookup: ((_host, options, cb) => {
|
|
44
|
+
if (options.all)
|
|
45
|
+
cb(null, records);
|
|
46
|
+
else
|
|
47
|
+
cb(null, records[0].address, records[0].family);
|
|
48
|
+
}),
|
|
49
|
+
}, resolve);
|
|
50
|
+
req.once('error', reject);
|
|
51
|
+
});
|
|
52
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode ?? 0) &&
|
|
53
|
+
res.headers.location) {
|
|
54
|
+
res.destroy();
|
|
55
|
+
return downloadPublicBytes(new URL(res.headers.location, url).href, cap, active, redirects + 1);
|
|
56
|
+
}
|
|
57
|
+
if (res.statusCode !== 200) {
|
|
58
|
+
res.destroy();
|
|
59
|
+
throw new Error('附件下载失败,请稍后重试');
|
|
60
|
+
}
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
try {
|
|
64
|
+
for await (const chunk of res) {
|
|
65
|
+
active.throwIfAborted();
|
|
66
|
+
const bytes = Buffer.from(chunk);
|
|
67
|
+
size += bytes.length;
|
|
68
|
+
if (size > cap)
|
|
69
|
+
throw new Error('附件超过下载大小上限');
|
|
70
|
+
chunks.push(bytes);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
res.destroy();
|
|
75
|
+
}
|
|
76
|
+
return Buffer.concat(chunks);
|
|
77
|
+
}
|
package/lib/runtime.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Live settings, account-pool ownership, and independent tool/web watch cursors. */
|
|
2
2
|
import { type EmailConfig, type ResolvedEmailSettings } from './config.js';
|
|
3
|
-
import {
|
|
3
|
+
import { EmailService } from './mail-service.js';
|
|
4
4
|
import { type EmailSettingsValue } from './settings.js';
|
|
5
5
|
import type { EmailWatchResult } from './types.js';
|
|
6
|
-
export type EmailClient = Pick<
|
|
6
|
+
export type EmailClient = Pick<EmailService, 'list' | 'read' | 'mark' | 'search' | 'send' | 'sendPrepared' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose' | 'watchApi'>;
|
|
7
7
|
export interface EmailSettingsScope {
|
|
8
8
|
get(): unknown;
|
|
9
9
|
namespace?: string;
|
package/lib/runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Live settings, account-pool ownership, and independent tool/web watch cursors. */
|
|
2
2
|
import { clampInt, mergeAccountDefaults, presetNamesIn, resolveEmailSettings, serializeAccountsYaml } from './config.js';
|
|
3
|
-
import {
|
|
3
|
+
import { messageOf } from './mail-client.js';
|
|
4
|
+
import { EmailService } from './mail-service.js';
|
|
4
5
|
import { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
|
|
5
6
|
import { liveConfig } from './host-config.js';
|
|
6
7
|
function fingerprintSettings(settings) {
|
|
@@ -18,7 +19,7 @@ function fingerprintSettings(settings) {
|
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
21
|
/** Register live settings and own exactly one pool for their effective value. */
|
|
21
|
-
export function createEmailRuntime(ctx, config, createPool = settings => new
|
|
22
|
+
export function createEmailRuntime(ctx, config, createPool = settings => new EmailService(settings)) {
|
|
22
23
|
config = liveConfig(config);
|
|
23
24
|
const legacy = typeof ctx.settings.register === 'function';
|
|
24
25
|
const namespace = legacy ? SETTINGS_NAMESPACE : ctx.fiber?.entry?.options.id ?? 'tool-email';
|
|
@@ -40,7 +41,7 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
|
|
|
40
41
|
endpointDefaults = { imap: account.imap, smtp: account.smtp };
|
|
41
42
|
}
|
|
42
43
|
catch { /* A fresh installation remains editable before credentials exist. */ }
|
|
43
|
-
const shared = Object.fromEntries(['provider', 'user', 'password', 'senderName', 'authUser', 'authPassword', 'clientId', 'authKind', 'imap', 'smtp', 'inboxFolder']
|
|
44
|
+
const shared = Object.fromEntries(['provider', 'user', 'password', 'apiKey', 'senderName', 'authUser', 'authPassword', 'clientId', 'authKind', 'imap', 'smtp', 'inboxFolder']
|
|
44
45
|
.filter(key => config[key] !== undefined).map(key => [key, config[key]]));
|
|
45
46
|
const cards = config.accounts && Object.keys(config.accounts).length > 0
|
|
46
47
|
? Object.fromEntries(Object.entries(config.accounts).map(([key, account]) => [key, mergeAccountDefaults(shared, account)]))
|
|
@@ -100,6 +101,9 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
|
|
|
100
101
|
const pool = getPool();
|
|
101
102
|
// 先只 SEARCH UNSEEN 拿 uid 列表与 totalUnread,再只为本轮要报告的最旧
|
|
102
103
|
// limit 封取信封:网页弹窗每 30 秒轮询一次,不能因为未读多就整批 FETCH。
|
|
104
|
+
const apiWatch = await pool.watchApi?.(account, folder, capped, scope, signal);
|
|
105
|
+
if (apiWatch)
|
|
106
|
+
return apiWatch;
|
|
103
107
|
const index = await pool.unseenUids(account, folder, signal);
|
|
104
108
|
const key = scope + '\u0000' + index.account + '\u0000' + index.folder;
|
|
105
109
|
const stored = watchCursors.get(key);
|
package/lib/settings.js
CHANGED
|
@@ -214,7 +214,7 @@ export function validateSettingsValue(value, extraProviders = []) {
|
|
|
214
214
|
parseRecipientPolicies(draft.trustedRecipientsYaml);
|
|
215
215
|
}
|
|
216
216
|
const provider = draft.provider;
|
|
217
|
-
if (isSet(provider) && provider !== '' && !PROVIDER_NAMES.includes(provider) && !extraProviders.includes(provider)) {
|
|
217
|
+
if (isSet(provider) && provider !== '' && !['agentmail', 'agentmail-eu', ...PROVIDER_NAMES].includes(provider) && !extraProviders.includes(provider)) {
|
|
218
218
|
const names = [...PROVIDER_NAMES, ...extraProviders];
|
|
219
219
|
throw new Error('未知的邮箱服务商 ' + describeValue(provider) + ',可选:' + names.join('/') + '(或留空手填 IMAP/SMTP 主机)');
|
|
220
220
|
}
|
package/lib/tool-contract.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare const ACCOUNT_HINT = "\u8D26\u53F7\u540D\uFF08\u914D\u7F6E\u4E86
|
|
|
10
10
|
export declare function parseEmailDay(input: string, label: string, endInclusive?: boolean): Date;
|
|
11
11
|
export declare function executionSignal(exec: unknown): AbortSignal | undefined;
|
|
12
12
|
interface ParameterProperty {
|
|
13
|
-
type?: string;
|
|
13
|
+
type?: string | string[];
|
|
14
14
|
required?: boolean;
|
|
15
15
|
description?: string;
|
|
16
16
|
items?: {
|
|
@@ -34,7 +34,9 @@ export declare const addrArray: {
|
|
|
34
34
|
};
|
|
35
35
|
export declare const messageShape: {
|
|
36
36
|
uid: {
|
|
37
|
-
|
|
37
|
+
oneOf: {
|
|
38
|
+
type: string;
|
|
39
|
+
}[];
|
|
38
40
|
};
|
|
39
41
|
date: {
|
|
40
42
|
type: string;
|
|
@@ -71,6 +73,18 @@ export declare const listSchema: {
|
|
|
71
73
|
count: {
|
|
72
74
|
type: string;
|
|
73
75
|
};
|
|
76
|
+
countExact: {
|
|
77
|
+
type: string;
|
|
78
|
+
};
|
|
79
|
+
hasMore: {
|
|
80
|
+
type: string;
|
|
81
|
+
};
|
|
82
|
+
order: {
|
|
83
|
+
type: string;
|
|
84
|
+
};
|
|
85
|
+
searchSource: {
|
|
86
|
+
type: string;
|
|
87
|
+
};
|
|
74
88
|
folder: {
|
|
75
89
|
type: string;
|
|
76
90
|
};
|
|
@@ -80,7 +94,9 @@ export declare const listSchema: {
|
|
|
80
94
|
type: string;
|
|
81
95
|
properties: {
|
|
82
96
|
uid: {
|
|
83
|
-
|
|
97
|
+
oneOf: {
|
|
98
|
+
type: string;
|
|
99
|
+
}[];
|
|
84
100
|
};
|
|
85
101
|
date: {
|
|
86
102
|
type: string;
|
|
@@ -121,7 +137,9 @@ export declare const readSchema: {
|
|
|
121
137
|
type: string;
|
|
122
138
|
};
|
|
123
139
|
uid: {
|
|
124
|
-
|
|
140
|
+
oneOf: {
|
|
141
|
+
type: string;
|
|
142
|
+
}[];
|
|
125
143
|
};
|
|
126
144
|
folder: {
|
|
127
145
|
type: string;
|
|
@@ -233,7 +251,9 @@ export declare const attachmentSchema: {
|
|
|
233
251
|
type: string;
|
|
234
252
|
};
|
|
235
253
|
uid: {
|
|
236
|
-
|
|
254
|
+
oneOf: {
|
|
255
|
+
type: string;
|
|
256
|
+
}[];
|
|
237
257
|
};
|
|
238
258
|
filename: {
|
|
239
259
|
type: string;
|
|
@@ -257,7 +277,9 @@ export declare const markSchema: {
|
|
|
257
277
|
type: string;
|
|
258
278
|
};
|
|
259
279
|
uid: {
|
|
260
|
-
|
|
280
|
+
oneOf: {
|
|
281
|
+
type: string;
|
|
282
|
+
}[];
|
|
261
283
|
};
|
|
262
284
|
folder: {
|
|
263
285
|
type: string;
|
|
@@ -306,7 +328,9 @@ export declare const replySchema: {
|
|
|
306
328
|
type: string;
|
|
307
329
|
};
|
|
308
330
|
originalUid: {
|
|
309
|
-
|
|
331
|
+
oneOf: {
|
|
332
|
+
type: string;
|
|
333
|
+
}[];
|
|
310
334
|
};
|
|
311
335
|
messageId: {
|
|
312
336
|
type: string;
|
|
@@ -359,13 +383,18 @@ export declare const watchSchema: {
|
|
|
359
383
|
totalUnread: {
|
|
360
384
|
type: string;
|
|
361
385
|
};
|
|
386
|
+
totalUnreadExact: {
|
|
387
|
+
type: string;
|
|
388
|
+
};
|
|
362
389
|
messages: {
|
|
363
390
|
type: string;
|
|
364
391
|
items: {
|
|
365
392
|
type: string;
|
|
366
393
|
properties: {
|
|
367
394
|
uid: {
|
|
368
|
-
|
|
395
|
+
oneOf: {
|
|
396
|
+
type: string;
|
|
397
|
+
}[];
|
|
369
398
|
};
|
|
370
399
|
date: {
|
|
371
400
|
type: string;
|
package/lib/tool-contract.js
CHANGED
|
@@ -35,6 +35,8 @@ export function compileParameters(spec) {
|
|
|
35
35
|
const node = {};
|
|
36
36
|
if (typeof prop?.type === 'string')
|
|
37
37
|
node.type = prop.type;
|
|
38
|
+
else if (Array.isArray(prop?.type))
|
|
39
|
+
node.oneOf = prop.type.map(type => ({ type }));
|
|
38
40
|
if (typeof prop?.description === 'string')
|
|
39
41
|
node.description = prop.description;
|
|
40
42
|
if (prop?.type === 'array' && prop.items !== null && typeof prop.items === 'object') {
|
|
@@ -58,7 +60,7 @@ export function normalizeAttachmentPaths(value) {
|
|
|
58
60
|
export const strArray = { type: 'array', items: { type: 'string' } };
|
|
59
61
|
export const addrArray = { type: 'array', items: { type: 'object', additionalProperties: true } };
|
|
60
62
|
export const messageShape = {
|
|
61
|
-
uid: { type: 'integer' },
|
|
63
|
+
uid: { oneOf: [{ type: 'integer' }, { type: 'string' }] },
|
|
62
64
|
date: { type: 'string' },
|
|
63
65
|
from: addrArray,
|
|
64
66
|
subject: { type: 'string' },
|
|
@@ -72,6 +74,10 @@ export const listSchema = {
|
|
|
72
74
|
properties: {
|
|
73
75
|
account: { type: 'string' },
|
|
74
76
|
count: { type: 'integer' },
|
|
77
|
+
countExact: { type: 'boolean' },
|
|
78
|
+
hasMore: { type: 'boolean' },
|
|
79
|
+
order: { type: 'string' },
|
|
80
|
+
searchSource: { type: 'string' },
|
|
75
81
|
folder: { type: 'string' },
|
|
76
82
|
messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
|
|
77
83
|
},
|
|
@@ -81,7 +87,7 @@ export const readSchema = {
|
|
|
81
87
|
type: 'object',
|
|
82
88
|
properties: {
|
|
83
89
|
account: { type: 'string' },
|
|
84
|
-
uid: { type: 'integer' },
|
|
90
|
+
uid: { oneOf: [{ type: 'integer' }, { type: 'string' }] },
|
|
85
91
|
folder: { type: 'string' },
|
|
86
92
|
date: { type: 'string' },
|
|
87
93
|
from: addrArray,
|
|
@@ -129,7 +135,7 @@ export const attachmentSchema = {
|
|
|
129
135
|
type: 'object',
|
|
130
136
|
properties: {
|
|
131
137
|
account: { type: 'string' },
|
|
132
|
-
uid: { type: 'integer' },
|
|
138
|
+
uid: { oneOf: [{ type: 'integer' }, { type: 'string' }] },
|
|
133
139
|
filename: { type: 'string' },
|
|
134
140
|
contentType: { type: 'string' },
|
|
135
141
|
size: { type: 'integer' },
|
|
@@ -141,7 +147,7 @@ export const markSchema = {
|
|
|
141
147
|
type: 'object',
|
|
142
148
|
properties: {
|
|
143
149
|
account: { type: 'string' },
|
|
144
|
-
uid: { type: 'integer' },
|
|
150
|
+
uid: { oneOf: [{ type: 'integer' }, { type: 'string' }] },
|
|
145
151
|
folder: { type: 'string' },
|
|
146
152
|
action: { type: 'string' },
|
|
147
153
|
seen: { type: 'boolean' },
|
|
@@ -181,6 +187,8 @@ export function describeMessage(message) {
|
|
|
181
187
|
return (message.subject || '(无主题)') + ' [' + parts.join(' | ') + ']';
|
|
182
188
|
}
|
|
183
189
|
export function renderList(value) {
|
|
190
|
+
if (value.countExact !== undefined)
|
|
191
|
+
return oneText('账号 ' + value.account + ',文件夹 ' + value.folder + ':本页 ' + value.messages.length + ' 封' + (value.hasMore ? ',还有更多' : '') + '(不把 API 页数当作邮箱总数)\n\n' + value.messages.map(describeMessage).join('\n'));
|
|
184
192
|
if (value.messages.length === 0) {
|
|
185
193
|
return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,本次没有要列出的邮件。');
|
|
186
194
|
}
|
|
@@ -195,6 +203,24 @@ export function renderRead(value) {
|
|
|
195
203
|
return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
|
|
196
204
|
}
|
|
197
205
|
export function renderSearch(value) {
|
|
206
|
+
if (value.searchSource === 'api')
|
|
207
|
+
return oneText('AgentMail 搜索(按相关性,服务排除垃圾箱等特殊邮件):本页 ' + value.messages.length + ' 条' + (value.hasMore ? ',还有更多;可增加 offset 继续' : '') + '\n\n' + value.messages.map(describeMessage).join('\n'));
|
|
208
|
+
if (value.headerScan) {
|
|
209
|
+
const scan = value.headerScan;
|
|
210
|
+
const window = scan.scanOffset === 0 ? '最近 ' + scan.scanned : '跳过最新 ' + scan.scanOffset + ' 封后的 ' + scan.scanned;
|
|
211
|
+
const completeness = scan.complete ? '完整信封扫描' : '未覆盖全文件夹';
|
|
212
|
+
const continuation = scan.nextScanOffset === undefined ? '' : ';继续更早信封可用 headerScanOffset=' + scan.nextScanOffset + '、headerScanLimit=' + scan.scanLimit;
|
|
213
|
+
const body = value.bodyScan ? ';正文只检查 ' + value.bodyScan.scanned + ' 封(上限 ' + value.bodyScan.scanLimit + '),正文未覆盖全文件夹' : ';未扫描正文';
|
|
214
|
+
const server = value.serverSearchFailed ? '服务端部分搜索失败' : '服务端候选 ' + (value.serverMatchCount ?? 0) + ' 条';
|
|
215
|
+
const note = server + ';已扫描' + window + ' 封的信封(文件夹共 ' + scan.mailboxTotal + ' 封,' + completeness + ',窗口内信封命中 ' + scan.matched + ' 条)' + continuation + body;
|
|
216
|
+
const summary = (value.messages.length ? '本页 ' + value.messages.length + ' 条' : '本页未命中,不能据此断言整个邮箱没有匹配') + (value.offset > 0 ? ',已跳过最新 ' + value.offset + ' 条匹配' : '');
|
|
217
|
+
const rows = value.messages.map((message, i) => '#' + (i + 1) + ' ' + describeMessage(message));
|
|
218
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":' + summary + '。' + note + (rows.length ? '\n\n' + rows.join('\n') : ''));
|
|
219
|
+
}
|
|
220
|
+
if (value.searchSource === 'server') {
|
|
221
|
+
const rows = value.messages.map((message, i) => '#' + (i + 1) + ' ' + describeMessage(message));
|
|
222
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":服务端报告 ' + value.count + ' 个候选;已核实本页 ' + value.messages.length + ' 条(跳过最新 ' + value.offset + ' 条)。' + (rows.length ? '\n\n' + rows.join('\n') : ''));
|
|
223
|
+
}
|
|
198
224
|
const scanned = value.countKind === 'scanned';
|
|
199
225
|
// 回退扫描只看了最近 scannedLimit 封,不知道全文件夹匹配数:不能把本页条数说成「共 N 条」。
|
|
200
226
|
const scannedNote = '仅扫描最近 ' + (value.scannedLimit ?? 0) + ' 封,未统计全文件夹匹配数';
|
|
@@ -242,14 +268,15 @@ export function renderReply(value) {
|
|
|
242
268
|
return oneText('账号 ' + value.account + ' 已' + REPLY_LABELS[value.mode] + ' uid=' + value.originalUid + ' 的邮件:收件人 ' + value.to.join(', ') + ',主题「' + value.subject + '」,messageId: ' + value.messageId + rejected);
|
|
243
269
|
}
|
|
244
270
|
export function renderWatch(value) {
|
|
271
|
+
const unread = (value.totalUnreadExact === false ? '本次扫描至少未读 ' : '当前未读 ') + value.totalUnread + ' 封';
|
|
245
272
|
if (value.reset === true) {
|
|
246
273
|
return oneText('账号 ' + value.account + ':文件夹 "' + value.folder + '" 的 UIDVALIDITY 已变化(服务器重新编号了邮件),已重新建立基线(当前未读 ' + value.totalUnread + ' 封)。这次不报告新邮件,之后照常。');
|
|
247
274
|
}
|
|
248
275
|
if (value.firstRun) {
|
|
249
|
-
return oneText('账号 ' + value.account + '
|
|
276
|
+
return oneText('账号 ' + value.account + ':已建立新邮件监视基线(' + unread + ')。之后调用 email_watch 只会报告新到的邮件。');
|
|
250
277
|
}
|
|
251
278
|
if (value.newCount === 0) {
|
|
252
|
-
return oneText('账号 ' + value.account + '
|
|
279
|
+
return oneText('账号 ' + value.account + ':没有新邮件(' + unread + ')。');
|
|
253
280
|
}
|
|
254
281
|
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
255
282
|
return oneText('账号 ' + value.account + ' 有 ' + value.newCount + ' 封新邮件:\n\n' + lines.join('\n') + '\n\n用 email_read 配合 uid 阅读全文。');
|
|
@@ -269,7 +296,7 @@ export const replySchema = {
|
|
|
269
296
|
properties: {
|
|
270
297
|
account: { type: 'string' },
|
|
271
298
|
mode: { type: 'string' },
|
|
272
|
-
originalUid: { type: 'integer' },
|
|
299
|
+
originalUid: { oneOf: [{ type: 'integer' }, { type: 'string' }] },
|
|
273
300
|
messageId: { type: 'string' },
|
|
274
301
|
accepted: strArray,
|
|
275
302
|
rejected: strArray,
|
|
@@ -288,20 +315,21 @@ export const watchSchema = {
|
|
|
288
315
|
reset: { type: 'boolean' },
|
|
289
316
|
newCount: { type: 'integer' },
|
|
290
317
|
totalUnread: { type: 'integer' },
|
|
318
|
+
totalUnreadExact: { type: 'boolean' },
|
|
291
319
|
messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
|
|
292
320
|
},
|
|
293
321
|
additionalProperties: true,
|
|
294
322
|
};
|
|
295
323
|
export const descriptions = {
|
|
296
|
-
"email_list": '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. Optional since/until (dates like 2026-08-01) filter by received date.',
|
|
324
|
+
"email_list": 'IMAP uid is a positive integer; AgentMail uid is an opaque string message_id. AgentMail returns page counts: inspect hasMore/countExact instead of assuming mailbox totals. 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. Optional since/until (dates like 2026-08-01) filter by received date.',
|
|
297
325
|
"email_read": '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.',
|
|
298
|
-
"email_mark": '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.',
|
|
299
|
-
"email_search": '
|
|
300
|
-
"email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the current session workspace; process cwd is the fallback without a session). Saved blocked-address/domain rules reject To/Cc recipients even when sendApproval is off. Plugin confirmation is required by default, unless sendApproval is disabled or every To/Cc recipient matches saved trust rules with skipApproval enabled. Host permissions still apply. Full Access refuses a send that still requires plugin confirmation; use email_draft for human review and explicit sending. Never invent recipients or content without the user\'s instruction.',
|
|
301
|
-
"email_reply": '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. Plugin confirmation is required unless sendApproval is disabled; trusted-recipient skipping only applies to email_send. Blocked recipients still stop delivery before SMTP and host permissions remain in effect. Never invent recipients or content without the user\'s instruction.',
|
|
326
|
+
"email_mark": 'AgentMail supports flags and only INBOX-to-Archive label archiving; arbitrary IMAP moves are not supported there. 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.',
|
|
327
|
+
"email_search": 'AgentMail accounts use API relevance search (searchSource=api), exclude spam/trash/blocked/unauthenticated mail, and page with offset; headerScanOffset and IMAP fallbacks do not apply. For IMAP: Search sender, recipients and subject; verify server candidates against envelopes and arrival dates. If server SEARCH is empty, fails, or has no confirmed matches, scan local headers in batches without downloading bodies/attachments (default newest 5000, max 20000). headerScanOffset/Limit select an older header window; use headerScan.nextScanOffset to continue. offset pages MATCHES within that window. If no headers match and headerScanOffset=0, optional body fallback scans only bodySearchLimit recent arrivals. Inspect searchSource/headerScan/bodyScan: partial or zero results never prove the whole mailbox has no match. Results use descending mailbox UID arrival order, not Date-header or INTERNALDATE sorting. since/until filter INTERNALDATE (Date header only if unavailable).',
|
|
328
|
+
"email_send": 'AgentMail has a plugin limit of 10 attachments / 4 MiB total and uses its inbox API; a receipt means submission, not delivery. Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the current session workspace; process cwd is the fallback without a session). Saved blocked-address/domain rules reject To/Cc recipients even when sendApproval is off. Plugin confirmation is required by default, unless sendApproval is disabled or every To/Cc recipient matches saved trust rules with skipApproval enabled. Host permissions still apply. Full Access refuses a send that still requires plugin confirmation; use email_draft for human review and explicit sending. Never invent recipients or content without the user\'s instruction.',
|
|
329
|
+
"email_reply": 'AgentMail reply/reply-all uses its thread-aware reply API; forwarding creates a new message. 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. Plugin confirmation is required unless sendApproval is disabled; trusted-recipient skipping only applies to email_send. Blocked recipients still stop delivery before SMTP and host permissions remain in effect. Never invent recipients or content without the user\'s instruction.',
|
|
302
330
|
"email_folders": '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.',
|
|
303
|
-
"email_health": '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.',
|
|
304
|
-
"email_attachment": '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.',
|
|
331
|
+
"email_health": 'AgentMail accounts show fixed regional API endpoints instead of IMAP/SMTP servers. 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.',
|
|
332
|
+
"email_attachment": 'AgentMail downloads are limited to 20 MiB or the smaller configured maximum. 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.',
|
|
305
333
|
"email_watch": '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.'
|
|
306
334
|
};
|
|
307
335
|
export const parameters = {
|
|
@@ -315,12 +343,12 @@ export const parameters = {
|
|
|
315
343
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
316
344
|
}),
|
|
317
345
|
"email_read": compileParameters({
|
|
318
|
-
uid: { type: 'integer', required: true, description: '
|
|
346
|
+
uid: { type: ['integer', 'string'], required: true, description: 'IMAP positive integer uid or unchanged AgentMail string message_id from email_list or email_search' },
|
|
319
347
|
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
320
348
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
321
349
|
}),
|
|
322
350
|
"email_mark": compileParameters({
|
|
323
|
-
uid: { type: 'integer', required: true, description: '
|
|
351
|
+
uid: { type: ['integer', 'string'], required: true, description: 'IMAP positive integer uid or unchanged AgentMail string message_id from email_list or email_search' },
|
|
324
352
|
action: { type: 'string', required: true, description: 'What to do: read, unread, star, unstar, or move' },
|
|
325
353
|
toFolder: { type: 'string', description: 'Destination folder path for action=move (see email_folders for valid paths)' },
|
|
326
354
|
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
@@ -331,6 +359,8 @@ export const parameters = {
|
|
|
331
359
|
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
|
|
332
360
|
limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
|
|
333
361
|
offset: { type: 'integer', description: 'Skip this many newest matches first, default 0' },
|
|
362
|
+
headerScanLimit: { type: 'integer', description: 'Local header fallback window size, 1-20000; default 5000. Headers only, no bodies or attachments.' },
|
|
363
|
+
headerScanOffset: { type: 'integer', description: 'Skip this many newest arrivals for the local header window; default 0. Continue with headerScan.nextScanOffset. Independent of match offset; a nonzero value skips body fallback.' },
|
|
334
364
|
since: { type: 'string', description: 'Only search messages received on or after this date, e.g. 2026-08-01 (optional)' },
|
|
335
365
|
until: { type: 'string', description: 'Only search messages received on or before this date, e.g. 2026-08-26 (optional)' },
|
|
336
366
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
@@ -344,7 +374,7 @@ export const parameters = {
|
|
|
344
374
|
account: { type: 'string', description: ACCOUNT_HINT },
|
|
345
375
|
}),
|
|
346
376
|
"email_reply": compileParameters({
|
|
347
|
-
uid: { type: 'integer', required: true, description: '
|
|
377
|
+
uid: { type: ['integer', 'string'], required: true, description: 'IMAP positive integer uid or unchanged AgentMail string message_id to answer/forward, from email_list or email_search' },
|
|
348
378
|
text: { type: 'string', required: true, description: 'The new text to write; the original is quoted below it automatically' },
|
|
349
379
|
mode: { type: 'string', description: 'reply (default), reply-all, or forward' },
|
|
350
380
|
to: { type: 'string', description: 'Recipient(s) for mode=forward, comma-separated' },
|
|
@@ -358,7 +388,7 @@ export const parameters = {
|
|
|
358
388
|
}),
|
|
359
389
|
"email_health": compileParameters({}),
|
|
360
390
|
"email_attachment": compileParameters({
|
|
361
|
-
uid: { type: 'integer', required: true, description: '
|
|
391
|
+
uid: { type: ['integer', 'string'], required: true, description: 'IMAP positive integer uid or unchanged AgentMail string message_id from email_list or email_search' },
|
|
362
392
|
index: { type: 'integer', description: '0-based attachment index, as listed by email_read; default 0' },
|
|
363
393
|
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
364
394
|
account: { type: 'string', description: ACCOUNT_HINT },
|
package/lib/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { descriptions, parameters, MAX_LIMIT, MARK_ACTIONS, REPLY_MODES, executi
|
|
|
6
6
|
/** 单次工具调用的 deadline:普通查询 60s,正文扫描/watch 这类整批操作 120s。 */
|
|
7
7
|
const QUERY_TIMEOUT_MS = 60000;
|
|
8
8
|
const BATCH_TIMEOUT_MS = 120000;
|
|
9
|
+
function validUid(uid) { return typeof uid === 'number' ? Number.isSafeInteger(uid) && uid > 0 : typeof uid === 'string' && uid.length > 0 && uid.length <= 998 && !/[\r\n\0]/.test(uid); }
|
|
9
10
|
function attachmentPaths(value, exec) {
|
|
10
11
|
const context = exec;
|
|
11
12
|
const cwd = context?.agent?.session?.header?.cwd;
|
|
@@ -52,8 +53,8 @@ export function buildEmailTools(runtime, drafts) {
|
|
|
52
53
|
timeoutMs: QUERY_TIMEOUT_MS,
|
|
53
54
|
async execute(rawArgs, exec) {
|
|
54
55
|
const args = rawArgs;
|
|
55
|
-
if (
|
|
56
|
-
throw new Error('uid
|
|
56
|
+
if (!validUid(args.uid)) {
|
|
57
|
+
throw new Error('uid 必须是正整数(IMAP)或 email_list 返回的字符串邮件标识(AgentMail)');
|
|
57
58
|
}
|
|
58
59
|
return await getPool().read(args.account, args.uid, args.folder?.trim() || '', executionSignal(exec));
|
|
59
60
|
}
|
|
@@ -69,8 +70,8 @@ export function buildEmailTools(runtime, drafts) {
|
|
|
69
70
|
timeoutMs: QUERY_TIMEOUT_MS,
|
|
70
71
|
async execute(rawArgs, exec) {
|
|
71
72
|
const args = rawArgs;
|
|
72
|
-
if (
|
|
73
|
-
throw new Error('uid
|
|
73
|
+
if (!validUid(args.uid)) {
|
|
74
|
+
throw new Error('uid 必须是正整数(IMAP)或 email_list 返回的字符串邮件标识(AgentMail)');
|
|
74
75
|
}
|
|
75
76
|
const action = (typeof args.action === 'string' ? args.action.trim().toLowerCase() : '');
|
|
76
77
|
if (!MARK_ACTIONS.includes(action)) {
|
|
@@ -99,7 +100,10 @@ export function buildEmailTools(runtime, drafts) {
|
|
|
99
100
|
const offset = clampInt(args.offset, 0, 0, 10000);
|
|
100
101
|
const since = args.since?.trim() ? parseEmailDay(args.since, 'since') : undefined;
|
|
101
102
|
const until = args.until?.trim() ? parseEmailDay(args.until, 'until', true) : undefined;
|
|
102
|
-
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, offset, since, until, executionSignal(exec)
|
|
103
|
+
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, offset, since, until, executionSignal(exec), {
|
|
104
|
+
headerScanLimit: clampInt(args.headerScanLimit, 5000, 1, 20000),
|
|
105
|
+
headerScanOffset: clampInt(args.headerScanOffset, 0, 0, 1_000_000_000),
|
|
106
|
+
});
|
|
103
107
|
}
|
|
104
108
|
},
|
|
105
109
|
{
|
|
@@ -128,8 +132,8 @@ export function buildEmailTools(runtime, drafts) {
|
|
|
128
132
|
timeoutMs: QUERY_TIMEOUT_MS,
|
|
129
133
|
async execute(rawArgs, exec) {
|
|
130
134
|
const args = rawArgs;
|
|
131
|
-
if (
|
|
132
|
-
throw new Error('uid
|
|
135
|
+
if (!validUid(args.uid)) {
|
|
136
|
+
throw new Error('uid 必须是正整数(IMAP)或 email_list 返回的字符串邮件标识(AgentMail)');
|
|
133
137
|
}
|
|
134
138
|
if (typeof args.text !== 'string' || args.text.trim() === '')
|
|
135
139
|
throw new Error('text 不能为空');
|
|
@@ -172,7 +176,7 @@ export function buildEmailTools(runtime, drafts) {
|
|
|
172
176
|
const entries = [...effective.accounts.entries()];
|
|
173
177
|
for (const [accountName, account] of entries.slice(0, 8)) {
|
|
174
178
|
const provider = Object.entries(PROVIDER_PRESETS).find(([, preset]) => (preset.imap.host === account.imap.host && preset.smtp.host === account.smtp.host))?.[0] ?? 'custom';
|
|
175
|
-
const base = provider + ' / ' + account.user + ' / IMAP ' + account.imap.host + ' / SMTP ' + account.smtp.host;
|
|
179
|
+
const base = account.transport === 'agentmail' ? 'AgentMail / ' + account.user + ' / ' + account.apiBase : provider + ' / ' + account.user + ' / IMAP ' + account.imap.host + ' / SMTP ' + account.smtp.host;
|
|
176
180
|
// An OAuth2 account with no token is configured, not broken: the
|
|
177
181
|
// missing piece is a browser login, and saying so is the whole
|
|
178
182
|
// point of this check.
|
|
@@ -211,8 +215,8 @@ export function buildEmailTools(runtime, drafts) {
|
|
|
211
215
|
timeoutMs: QUERY_TIMEOUT_MS,
|
|
212
216
|
async execute(rawArgs, exec) {
|
|
213
217
|
const args = rawArgs;
|
|
214
|
-
if (
|
|
215
|
-
throw new Error('uid
|
|
218
|
+
if (!validUid(args.uid)) {
|
|
219
|
+
throw new Error('uid 必须是正整数(IMAP)或 email_list 返回的字符串邮件标识(AgentMail)');
|
|
216
220
|
}
|
|
217
221
|
const index = clampInt(args.index, 0, 0, 999);
|
|
218
222
|
const workspaceHint = typeof exec?.agent?.session?.header?.cwd === 'string' ? exec.agent.session.header.cwd : undefined;
|