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.
@@ -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 { EmailPool } from './mail-client.js';
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<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'sendPrepared' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
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 { EmailPool, messageOf } from './mail-client.js';
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 EmailPool(settings)) {
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
  }
@@ -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
- type: string;
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
- type: string;
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
- type: string;
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
- type: string;
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
- type: string;
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
- type: string;
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
- type: string;
395
+ oneOf: {
396
+ type: string;
397
+ }[];
369
398
  };
370
399
  date: {
371
400
  type: string;
@@ -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 + ':已建立新邮件监视基线(当前未读 ' + value.totalUnread + ' 封)。之后调用 email_watch 只会报告新到的邮件。');
276
+ return oneText('账号 ' + value.account + ':已建立新邮件监视基线(' + unread + ')。之后调用 email_watch 只会报告新到的邮件。');
250
277
  }
251
278
  if (value.newCount === 0) {
252
- return oneText('账号 ' + value.account + ':没有新邮件(当前未读 ' + value.totalUnread + ' 封)。');
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": 'Search emails by a keyword. The server first searches sender, recipients and subject; those hits are re-verified against the envelopes and, when none of them really carries the keyword (some servers answer every search with the same uids), recent messages are scanned locally including their body while bodySearchFallback is enabled. offset skips the newest matches for paging; since/until still constrain both paths. Returns the same compact rows as email_list.',
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: 'Message uid from email_list or email_search' },
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: 'Message uid from email_list or email_search' },
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: 'Message uid to answer/forward, from email_list or email_search' },
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: 'Message uid from email_list or email_search' },
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 (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
56
- throw new Error('uid 必须是正整数(用 email_list 获取)');
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 (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
73
- throw new Error('uid 必须是正整数(用 email_list 获取)');
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 (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
132
- throw new Error('uid 必须是正整数(用 email_list 获取)');
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 (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
215
- throw new Error('uid 必须是正整数(用 email_list 获取)');
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;