dsh-email 0.10.2 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -1
- package/README.md +5 -1
- package/lib/approval.d.ts +34 -0
- package/lib/approval.js +55 -0
- package/lib/index.d.ts +8 -12
- package/lib/index.js +17 -669
- package/lib/mail-client.d.ts +12 -10
- package/lib/mail-client.js +141 -52
- package/lib/runtime.d.ts +36 -0
- package/lib/runtime.js +92 -0
- package/lib/tool-contract.d.ts +423 -0
- package/lib/tool-contract.js +353 -0
- package/lib/tools.d.ts +13 -0
- package/lib/tools.js +191 -0
- package/package.json +1 -1
package/lib/mail-client.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare class EmailPool {
|
|
|
66
66
|
resolveName(name?: string): string;
|
|
67
67
|
/** Serialize operations per account: one IMAP connection serves one op at a time. */
|
|
68
68
|
private enqueue;
|
|
69
|
-
withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>, readOnly?: boolean): Promise<T>;
|
|
69
|
+
withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>, readOnly?: boolean, signal?: AbortSignal): Promise<T>;
|
|
70
70
|
private createImap;
|
|
71
71
|
private imapRun;
|
|
72
72
|
private normalizeImapError;
|
|
@@ -75,20 +75,22 @@ export declare class EmailPool {
|
|
|
75
75
|
startIdleSweep(): void;
|
|
76
76
|
dispose(): void;
|
|
77
77
|
private transporter;
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
/** Send through the pooled transporter while making cancellation close it. */
|
|
79
|
+
private sendMail;
|
|
80
|
+
list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailListResult>;
|
|
81
|
+
search(accountName: string | undefined, query: string, folder: string, limit: number, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailSearchResult>;
|
|
80
82
|
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
81
83
|
private searchBodies;
|
|
82
84
|
private fetchListed;
|
|
83
|
-
read(accountName: string | undefined, uid: number, folder: string): Promise<EmailReadResult>;
|
|
84
|
-
mark(accountName: string | undefined, folder: string, uid: number, action: EmailMarkAction, toFolder?: string): Promise<EmailMarkResult>;
|
|
85
|
-
folders(accountName: string | undefined, subscribedOnly: boolean): Promise<EmailFoldersResult>;
|
|
86
|
-
downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number, workspaceHint?: string): Promise<EmailAttachmentResult>;
|
|
87
|
-
send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined): Promise<EmailSendResult>;
|
|
88
|
-
reply(accountName: string | undefined, folder: string, uid: number, mode: EmailReplyMode, text: string, forwardTo: string, cc: string | undefined): Promise<EmailReplyResult>;
|
|
85
|
+
read(accountName: string | undefined, uid: number, folder: string, signal?: AbortSignal): Promise<EmailReadResult>;
|
|
86
|
+
mark(accountName: string | undefined, folder: string, uid: number, action: EmailMarkAction, toFolder?: string, signal?: AbortSignal): Promise<EmailMarkResult>;
|
|
87
|
+
folders(accountName: string | undefined, subscribedOnly: boolean, signal?: AbortSignal): Promise<EmailFoldersResult>;
|
|
88
|
+
downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number, workspaceHint?: string, signal?: AbortSignal): Promise<EmailAttachmentResult>;
|
|
89
|
+
send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined, signal?: AbortSignal): Promise<EmailSendResult>;
|
|
90
|
+
reply(accountName: string | undefined, folder: string, uid: number, mode: EmailReplyMode, text: string, forwardTo: string, cc: string | undefined, signal?: AbortSignal): Promise<EmailReplyResult>;
|
|
89
91
|
}
|
|
90
92
|
/** Stat every attachment path up front; total size must stay under the cap. */
|
|
91
|
-
export declare function validateAttachmentPaths(paths: string[], maxBytes: number): Promise<Array<{
|
|
93
|
+
export declare function validateAttachmentPaths(paths: string[], maxBytes: number, signal?: AbortSignal): Promise<Array<{
|
|
92
94
|
path: string;
|
|
93
95
|
}>>;
|
|
94
96
|
export {};
|
package/lib/mail-client.js
CHANGED
|
@@ -194,16 +194,20 @@ export class EmailPool {
|
|
|
194
194
|
return name?.trim() || this.settings.defaultAccount;
|
|
195
195
|
}
|
|
196
196
|
/** Serialize operations per account: one IMAP connection serves one op at a time. */
|
|
197
|
-
enqueue(name, task) {
|
|
197
|
+
enqueue(name, task, signal) {
|
|
198
198
|
const prev = this.queues.get(name) ?? Promise.resolve();
|
|
199
|
-
const
|
|
199
|
+
const run = async () => {
|
|
200
|
+
signal?.throwIfAborted();
|
|
201
|
+
return task();
|
|
202
|
+
};
|
|
203
|
+
const next = prev.then(run, run);
|
|
200
204
|
this.queues.set(name, next.then(() => undefined, () => undefined));
|
|
201
205
|
return next;
|
|
202
206
|
}
|
|
203
|
-
async withImap(accountName, folder, run, readOnly = true) {
|
|
207
|
+
async withImap(accountName, folder, run, readOnly = true, signal) {
|
|
204
208
|
const name = this.resolveName(accountName);
|
|
205
209
|
const cfg = this.account(name);
|
|
206
|
-
return this.enqueue(name, () => this.imapRun(name, cfg, folder, readOnly, run));
|
|
210
|
+
return this.enqueue(name, () => this.imapRun(name, cfg, folder, readOnly, run, signal), signal);
|
|
207
211
|
}
|
|
208
212
|
createImap(cfg) {
|
|
209
213
|
const client = new ImapFlow({
|
|
@@ -230,35 +234,55 @@ export class EmailPool {
|
|
|
230
234
|
});
|
|
231
235
|
return client;
|
|
232
236
|
}
|
|
233
|
-
async imapRun(name, cfg, folder, readOnly, run) {
|
|
237
|
+
async imapRun(name, cfg, folder, readOnly, run, signal) {
|
|
234
238
|
let entry = this.imaps.get(name);
|
|
239
|
+
let activeClient = entry?.client;
|
|
240
|
+
const onAbort = () => {
|
|
241
|
+
// ImapFlow has no per-command AbortSignal option. Closing the owned
|
|
242
|
+
// connection is its cooperative cancellation mechanism and makes the
|
|
243
|
+
// in-flight command settle before this method returns.
|
|
244
|
+
try {
|
|
245
|
+
activeClient?.close();
|
|
246
|
+
}
|
|
247
|
+
catch { /* already closed */ }
|
|
248
|
+
};
|
|
249
|
+
signal?.throwIfAborted();
|
|
250
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
235
251
|
try {
|
|
236
252
|
if (entry === undefined || !entry.client.usable) {
|
|
237
253
|
if (entry !== undefined)
|
|
238
254
|
await this.evictImap(name);
|
|
239
255
|
const client = this.createImap(cfg);
|
|
256
|
+
activeClient = client;
|
|
257
|
+
signal?.throwIfAborted();
|
|
240
258
|
await client.connect();
|
|
259
|
+
signal?.throwIfAborted();
|
|
241
260
|
entry = { client, selected: null, selectedReadOnly: true, lastUsed: Date.now(), inUse: 0 };
|
|
242
261
|
this.imaps.set(name, entry);
|
|
243
262
|
}
|
|
263
|
+
activeClient = entry.client;
|
|
244
264
|
entry.lastUsed = Date.now();
|
|
245
265
|
entry.inUse += 1;
|
|
246
266
|
// Reopen when the folder changes or when the caller needs a different
|
|
247
267
|
// access mode (email_mark writes flags / moves messages).
|
|
248
268
|
if (folder !== null && (entry.selected !== folder || entry.selectedReadOnly !== readOnly)) {
|
|
249
269
|
await entry.client.mailboxOpen(folder, { readOnly });
|
|
270
|
+
signal?.throwIfAborted();
|
|
250
271
|
entry.selected = folder;
|
|
251
272
|
entry.selectedReadOnly = readOnly;
|
|
252
273
|
}
|
|
253
274
|
const result = await run(entry.client);
|
|
275
|
+
signal?.throwIfAborted();
|
|
254
276
|
entry.lastUsed = Date.now();
|
|
255
277
|
return result;
|
|
256
278
|
}
|
|
257
279
|
catch (error) {
|
|
258
280
|
await this.evictImap(name);
|
|
281
|
+
signal?.throwIfAborted();
|
|
259
282
|
throw this.normalizeImapError(error, folder);
|
|
260
283
|
}
|
|
261
284
|
finally {
|
|
285
|
+
signal?.removeEventListener('abort', onAbort);
|
|
262
286
|
if (entry !== undefined)
|
|
263
287
|
entry.inUse = Math.max(0, entry.inUse - 1);
|
|
264
288
|
}
|
|
@@ -328,7 +352,30 @@ export class EmailPool {
|
|
|
328
352
|
}
|
|
329
353
|
return t;
|
|
330
354
|
}
|
|
331
|
-
|
|
355
|
+
/** Send through the pooled transporter while making cancellation close it. */
|
|
356
|
+
async sendMail(name, cfg, message, signal) {
|
|
357
|
+
signal?.throwIfAborted();
|
|
358
|
+
const transporter = this.transporter(name, cfg);
|
|
359
|
+
const onAbort = () => {
|
|
360
|
+
if (this.smtps.get(name) === transporter)
|
|
361
|
+
this.smtps.delete(name);
|
|
362
|
+
transporter.close();
|
|
363
|
+
};
|
|
364
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
365
|
+
try {
|
|
366
|
+
const info = await transporter.sendMail(message);
|
|
367
|
+
signal?.throwIfAborted();
|
|
368
|
+
return info;
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
signal?.throwIfAborted();
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
signal?.removeEventListener('abort', onAbort);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async list(accountName, folder, limit, offset, unreadOnly, since, until, signal) {
|
|
332
379
|
const name = this.resolveName(accountName);
|
|
333
380
|
const cfg = this.account(name);
|
|
334
381
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -347,21 +394,23 @@ export class EmailPool {
|
|
|
347
394
|
if (until !== undefined)
|
|
348
395
|
query.before = until;
|
|
349
396
|
const found = await client.search(query, { uid: true });
|
|
397
|
+
signal?.throwIfAborted();
|
|
350
398
|
uids = found === false ? [] : found;
|
|
351
399
|
scopeCount = uids.length;
|
|
352
400
|
}
|
|
353
401
|
else if (total > 0) {
|
|
354
402
|
const start = Math.max(1, total - (limit + offset) + 1);
|
|
355
403
|
const fetched = await client.fetchAll(start + ':*', { uid: true });
|
|
404
|
+
signal?.throwIfAborted();
|
|
356
405
|
uids = fetched.map(message => message.uid);
|
|
357
406
|
}
|
|
358
407
|
uids.reverse();
|
|
359
408
|
const window = uids.slice(offset, offset + limit);
|
|
360
|
-
const messages = await this.fetchListed(client, window);
|
|
409
|
+
const messages = await this.fetchListed(client, window, signal);
|
|
361
410
|
return { account: name, count: scopeCount, folder: folderName, messages };
|
|
362
|
-
});
|
|
411
|
+
}, true, signal);
|
|
363
412
|
}
|
|
364
|
-
async search(accountName, query, folder, limit, since, until) {
|
|
413
|
+
async search(accountName, query, folder, limit, since, until, signal) {
|
|
365
414
|
const name = this.resolveName(accountName);
|
|
366
415
|
const cfg = this.account(name);
|
|
367
416
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -380,30 +429,38 @@ export class EmailPool {
|
|
|
380
429
|
client.search({ to: query, ...dateRange }, { uid: true }),
|
|
381
430
|
client.search({ cc: query, ...dateRange }, { uid: true }),
|
|
382
431
|
]);
|
|
432
|
+
signal?.throwIfAborted();
|
|
383
433
|
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => a - b);
|
|
384
434
|
uids.reverse();
|
|
385
435
|
if (uids.length === 0 && this.settings.bodySearchFallback) {
|
|
386
436
|
// Server-side search found nothing: fall back to a client-side scan of
|
|
387
437
|
// the most recent messages (subject/from/body), capped for time.
|
|
388
|
-
const messages = await this.searchBodies(client, query, folderName, limit);
|
|
438
|
+
const messages = await this.searchBodies(client, query, folderName, limit, since, until, signal);
|
|
389
439
|
return { account: name, query, count: messages.length, folder: folderName, messages };
|
|
390
440
|
}
|
|
391
|
-
const messages = await this.fetchListed(client, uids.slice(0, limit));
|
|
441
|
+
const messages = await this.fetchListed(client, uids.slice(0, limit), signal);
|
|
392
442
|
return { account: name, query, count: uids.length, folder: folderName, messages };
|
|
393
|
-
});
|
|
443
|
+
}, true, signal);
|
|
394
444
|
}
|
|
395
445
|
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
396
|
-
async searchBodies(client, query, folder, limit) {
|
|
446
|
+
async searchBodies(client, query, folder, limit, since, until, signal) {
|
|
447
|
+
signal?.throwIfAborted();
|
|
397
448
|
const mailbox = client.mailbox;
|
|
398
449
|
const total = mailbox === false ? 0 : mailbox.exists;
|
|
399
450
|
if (total === 0)
|
|
400
451
|
return [];
|
|
401
452
|
const start = Math.max(1, total - this.settings.bodySearchLimit + 1);
|
|
402
|
-
const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, source: true });
|
|
453
|
+
const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, source: true, internalDate: true });
|
|
403
454
|
const out = [];
|
|
404
455
|
for (const message of [...fetched].reverse()) {
|
|
456
|
+
signal?.throwIfAborted();
|
|
405
457
|
if (out.length >= limit)
|
|
406
458
|
break;
|
|
459
|
+
const receivedAt = message.internalDate ?? message.envelope?.date;
|
|
460
|
+
if (since !== undefined && (receivedAt === undefined || receivedAt < since))
|
|
461
|
+
continue;
|
|
462
|
+
if (until !== undefined && (receivedAt === undefined || receivedAt >= until))
|
|
463
|
+
continue;
|
|
407
464
|
const subject = message.envelope?.subject ?? '';
|
|
408
465
|
const recipientSearchText = [message.envelope?.from, message.envelope?.to, message.envelope?.cc]
|
|
409
466
|
.map(flattenAddressText).join(' ');
|
|
@@ -411,9 +468,11 @@ export class EmailPool {
|
|
|
411
468
|
if (message.source !== undefined) {
|
|
412
469
|
try {
|
|
413
470
|
const parsed = await parseRawMessage(message.source, 4096);
|
|
471
|
+
signal?.throwIfAborted();
|
|
414
472
|
body = parsed.text;
|
|
415
473
|
}
|
|
416
|
-
catch {
|
|
474
|
+
catch (error) {
|
|
475
|
+
signal?.throwIfAborted();
|
|
417
476
|
// 单封邮件解析失败不应中断整批回退扫描,继续用 subject/from/to/cc 匹配。
|
|
418
477
|
}
|
|
419
478
|
}
|
|
@@ -423,15 +482,17 @@ export class EmailPool {
|
|
|
423
482
|
}
|
|
424
483
|
return out;
|
|
425
484
|
}
|
|
426
|
-
async fetchListed(client, uids) {
|
|
485
|
+
async fetchListed(client, uids, signal) {
|
|
486
|
+
signal?.throwIfAborted();
|
|
427
487
|
if (uids.length === 0)
|
|
428
488
|
return [];
|
|
429
489
|
const fetched = await client.fetchAll(uids, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
|
|
490
|
+
signal?.throwIfAborted();
|
|
430
491
|
return fetched
|
|
431
492
|
.map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
|
|
432
493
|
.sort((a, b) => b.uid - a.uid);
|
|
433
494
|
}
|
|
434
|
-
async read(accountName, uid, folder) {
|
|
495
|
+
async read(accountName, uid, folder, signal) {
|
|
435
496
|
const name = this.resolveName(accountName);
|
|
436
497
|
const cfg = this.account(name);
|
|
437
498
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -441,15 +502,17 @@ export class EmailPool {
|
|
|
441
502
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
442
503
|
}
|
|
443
504
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
505
|
+
signal?.throwIfAborted();
|
|
444
506
|
return { account: name, uid, folder: folderName, ...body };
|
|
445
|
-
});
|
|
507
|
+
}, true, signal);
|
|
446
508
|
}
|
|
447
|
-
async mark(accountName, folder, uid, action, toFolder) {
|
|
509
|
+
async mark(accountName, folder, uid, action, toFolder, signal) {
|
|
448
510
|
const name = this.resolveName(accountName);
|
|
449
511
|
const cfg = this.account(name);
|
|
450
512
|
const folderName = folder || cfg.inboxFolder;
|
|
451
513
|
return this.withImap(name, folderName, async (client) => {
|
|
452
514
|
const before = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
|
|
515
|
+
signal?.throwIfAborted();
|
|
453
516
|
if (before === false) {
|
|
454
517
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
455
518
|
}
|
|
@@ -457,18 +520,22 @@ export class EmailPool {
|
|
|
457
520
|
let flagged = before.flags?.has('\\Flagged') === true;
|
|
458
521
|
if (action === 'read' && !seen) {
|
|
459
522
|
await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
|
|
523
|
+
signal?.throwIfAborted();
|
|
460
524
|
seen = true;
|
|
461
525
|
}
|
|
462
526
|
else if (action === 'unread' && seen) {
|
|
463
527
|
await client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
|
|
528
|
+
signal?.throwIfAborted();
|
|
464
529
|
seen = false;
|
|
465
530
|
}
|
|
466
531
|
else if (action === 'star' && !flagged) {
|
|
467
532
|
await client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
|
|
533
|
+
signal?.throwIfAborted();
|
|
468
534
|
flagged = true;
|
|
469
535
|
}
|
|
470
536
|
else if (action === 'unstar' && flagged) {
|
|
471
537
|
await client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
|
|
538
|
+
signal?.throwIfAborted();
|
|
472
539
|
flagged = false;
|
|
473
540
|
}
|
|
474
541
|
else if (action === 'move') {
|
|
@@ -478,10 +545,12 @@ export class EmailPool {
|
|
|
478
545
|
if (target === folderName)
|
|
479
546
|
throw new MailError('邮件已在文件夹 "' + folderName + '" 中,无需移动');
|
|
480
547
|
const folders = await client.list();
|
|
548
|
+
signal?.throwIfAborted();
|
|
481
549
|
if (!folders.some(row => row.path === target)) {
|
|
482
550
|
throw new MailError('找不到目标文件夹 "' + target + '",可用:' + folders.map(row => row.path).join('、'));
|
|
483
551
|
}
|
|
484
552
|
const moved = await client.messageMove(uid, target, { uid: true });
|
|
553
|
+
signal?.throwIfAborted();
|
|
485
554
|
if (moved === false)
|
|
486
555
|
throw new MailError('移动 uid=' + uid + ' 到 "' + target + '" 失败(服务器拒绝了 MOVE/COPY)');
|
|
487
556
|
const result = { account: name, uid, folder: folderName, action, seen, flagged, movedTo: target };
|
|
@@ -491,12 +560,13 @@ export class EmailPool {
|
|
|
491
560
|
return result;
|
|
492
561
|
}
|
|
493
562
|
return { account: name, uid, folder: folderName, action, seen, flagged };
|
|
494
|
-
}, false);
|
|
563
|
+
}, false, signal);
|
|
495
564
|
}
|
|
496
|
-
async folders(accountName, subscribedOnly) {
|
|
565
|
+
async folders(accountName, subscribedOnly, signal) {
|
|
497
566
|
const name = this.resolveName(accountName);
|
|
498
567
|
return this.withImap(name, null, async (client) => {
|
|
499
568
|
const list = await client.list();
|
|
569
|
+
signal?.throwIfAborted();
|
|
500
570
|
const folders = list
|
|
501
571
|
.filter(row => !subscribedOnly || row.subscribed !== false)
|
|
502
572
|
.map(row => ({
|
|
@@ -506,9 +576,9 @@ export class EmailPool {
|
|
|
506
576
|
subscribed: row.subscribed !== false,
|
|
507
577
|
}));
|
|
508
578
|
return { account: name, folders };
|
|
509
|
-
});
|
|
579
|
+
}, true, signal);
|
|
510
580
|
}
|
|
511
|
-
async downloadAttachment(accountName, folder, uid, index, workspaceHint) {
|
|
581
|
+
async downloadAttachment(accountName, folder, uid, index, workspaceHint, signal) {
|
|
512
582
|
const name = this.resolveName(accountName);
|
|
513
583
|
const cfg = this.account(name);
|
|
514
584
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -520,6 +590,7 @@ export class EmailPool {
|
|
|
520
590
|
// The mailparser list is authoritative for the index email_read showed;
|
|
521
591
|
// the bodyStructure walk supplies the IMAP part to download.
|
|
522
592
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
593
|
+
signal?.throwIfAborted();
|
|
523
594
|
const parts = collectAttachmentParts(message.bodyStructure);
|
|
524
595
|
if (body.attachments.length === 0)
|
|
525
596
|
throw new MailError('该邮件没有附件');
|
|
@@ -534,7 +605,8 @@ export class EmailPool {
|
|
|
534
605
|
throw new MailError('附件 "' + att.filename + '" 大小 ' + att.size + ' 字节,超过上限 maxAttachmentBytes=' + this.settings.maxAttachmentBytes);
|
|
535
606
|
}
|
|
536
607
|
const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
|
|
537
|
-
|
|
608
|
+
signal?.throwIfAborted();
|
|
609
|
+
const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
|
|
538
610
|
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? body.attachments[index].filename);
|
|
539
611
|
// Default the destination to the session workspace so the model can
|
|
540
612
|
// read the file back; an explicit downloadDir always wins.
|
|
@@ -544,32 +616,34 @@ export class EmailPool {
|
|
|
544
616
|
? join(workspaceHint, '.dsh-email-downloads')
|
|
545
617
|
: this.settings.downloadDir);
|
|
546
618
|
await mkdir(dir, { recursive: true });
|
|
619
|
+
signal?.throwIfAborted();
|
|
547
620
|
const dest = await uniquePath(join(dir, safeName));
|
|
548
|
-
|
|
621
|
+
signal?.throwIfAborted();
|
|
622
|
+
await writeFile(dest, buf, signal === undefined ? undefined : { signal });
|
|
549
623
|
return { account: name, uid, filename: safeName, contentType: att.contentType, size: buf.length, path: dest };
|
|
550
|
-
});
|
|
624
|
+
}, true, signal);
|
|
551
625
|
}
|
|
552
|
-
async send(accountName, to, subject, text, cc, attachmentPaths) {
|
|
626
|
+
async send(accountName, to, subject, text, cc, attachmentPaths, signal) {
|
|
553
627
|
const name = this.resolveName(accountName);
|
|
554
628
|
const cfg = this.account(name);
|
|
555
|
-
const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes);
|
|
556
|
-
const info = await this.
|
|
629
|
+
const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes, signal);
|
|
630
|
+
const info = await this.sendMail(name, cfg, {
|
|
557
631
|
from: cfg.user,
|
|
558
632
|
to,
|
|
559
633
|
cc,
|
|
560
634
|
subject,
|
|
561
635
|
text: text ?? '',
|
|
562
636
|
attachments,
|
|
563
|
-
});
|
|
637
|
+
}, signal);
|
|
564
638
|
return {
|
|
565
639
|
account: name,
|
|
566
|
-
messageId: info.messageId,
|
|
567
|
-
accepted: info.accepted.map(String),
|
|
568
|
-
rejected: info.rejected.map(String),
|
|
569
|
-
response: info.response,
|
|
640
|
+
messageId: typeof info.messageId === 'string' ? info.messageId : String(info.messageId ?? ''),
|
|
641
|
+
accepted: Array.isArray(info.accepted) ? info.accepted.map(String) : [],
|
|
642
|
+
rejected: Array.isArray(info.rejected) ? info.rejected.map(String) : [],
|
|
643
|
+
response: typeof info.response === 'string' ? info.response : String(info.response ?? ''),
|
|
570
644
|
};
|
|
571
645
|
}
|
|
572
|
-
async reply(accountName, folder, uid, mode, text, forwardTo, cc) {
|
|
646
|
+
async reply(accountName, folder, uid, mode, text, forwardTo, cc, signal) {
|
|
573
647
|
const name = this.resolveName(accountName);
|
|
574
648
|
const cfg = this.account(name);
|
|
575
649
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -582,9 +656,10 @@ export class EmailPool {
|
|
|
582
656
|
}
|
|
583
657
|
const ids = extractMessageIds(message.source);
|
|
584
658
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
659
|
+
signal?.throwIfAborted();
|
|
585
660
|
return buildReplyMessage({ from: body.from, to: body.to, cc: body.cc, subject: body.subject, date: body.date, text: body.text, messageId: ids.messageId, references: ids.references }, mode, cfg.user, text, forwardTo);
|
|
586
|
-
});
|
|
587
|
-
const info = await this.
|
|
661
|
+
}, true, signal);
|
|
662
|
+
const info = await this.sendMail(name, cfg, {
|
|
588
663
|
from: cfg.user,
|
|
589
664
|
to: built.to,
|
|
590
665
|
cc,
|
|
@@ -592,25 +667,26 @@ export class EmailPool {
|
|
|
592
667
|
text: built.text,
|
|
593
668
|
...(built.inReplyTo !== undefined ? { inReplyTo: '<' + built.inReplyTo + '>' } : {}),
|
|
594
669
|
...(built.references !== undefined ? { references: built.references.split(' ').map(id => '<' + id + '>') } : {}),
|
|
595
|
-
});
|
|
670
|
+
}, signal);
|
|
596
671
|
return {
|
|
597
672
|
account: name,
|
|
598
673
|
mode,
|
|
599
674
|
originalUid: uid,
|
|
600
|
-
messageId: info.messageId,
|
|
601
|
-
accepted: info.accepted.map(String),
|
|
602
|
-
rejected: info.rejected.map(String),
|
|
603
|
-
response: info.response,
|
|
675
|
+
messageId: typeof info.messageId === 'string' ? info.messageId : String(info.messageId ?? ''),
|
|
676
|
+
accepted: Array.isArray(info.accepted) ? info.accepted.map(String) : [],
|
|
677
|
+
rejected: Array.isArray(info.rejected) ? info.rejected.map(String) : [],
|
|
678
|
+
response: typeof info.response === 'string' ? info.response : String(info.response ?? ''),
|
|
604
679
|
to: built.to.split(',').map(part => part.trim()).filter(part => part !== ''),
|
|
605
680
|
subject: built.subject,
|
|
606
681
|
};
|
|
607
682
|
}
|
|
608
683
|
}
|
|
609
684
|
/** Stat every attachment path up front; total size must stay under the cap. */
|
|
610
|
-
export async function validateAttachmentPaths(paths, maxBytes) {
|
|
685
|
+
export async function validateAttachmentPaths(paths, maxBytes, signal) {
|
|
611
686
|
const out = [];
|
|
612
687
|
let total = 0;
|
|
613
688
|
for (const rawPath of paths) {
|
|
689
|
+
signal?.throwIfAborted();
|
|
614
690
|
if (typeof rawPath !== 'string' || rawPath.trim() === '') {
|
|
615
691
|
throw new MailError('附件路径无效:' + String(rawPath));
|
|
616
692
|
}
|
|
@@ -622,6 +698,7 @@ export async function validateAttachmentPaths(paths, maxBytes) {
|
|
|
622
698
|
catch {
|
|
623
699
|
throw new MailError('附件路径不存在或不可读:' + path);
|
|
624
700
|
}
|
|
701
|
+
signal?.throwIfAborted();
|
|
625
702
|
if (!info.isFile())
|
|
626
703
|
throw new MailError('附件路径不是文件:' + path);
|
|
627
704
|
total += info.size;
|
|
@@ -633,17 +710,29 @@ export async function validateAttachmentPaths(paths, maxBytes) {
|
|
|
633
710
|
return out;
|
|
634
711
|
}
|
|
635
712
|
/** Drain a download stream into a Buffer with a hard byte cap. */
|
|
636
|
-
async function collectStream(stream, maxBytes) {
|
|
713
|
+
async function collectStream(stream, maxBytes, signal) {
|
|
637
714
|
const chunks = [];
|
|
638
715
|
let total = 0;
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
716
|
+
const onAbort = () => {
|
|
717
|
+
stream.destroy(signal?.reason instanceof Error ? signal.reason : new Error('邮件附件下载已取消'));
|
|
718
|
+
};
|
|
719
|
+
signal?.throwIfAborted();
|
|
720
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
721
|
+
try {
|
|
722
|
+
for await (const chunk of stream) {
|
|
723
|
+
signal?.throwIfAborted();
|
|
724
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
725
|
+
total += buf.length;
|
|
726
|
+
if (total > maxBytes)
|
|
727
|
+
throw new MailError('附件超过上限 maxAttachmentBytes=' + maxBytes + ' 字节,下载中止');
|
|
728
|
+
chunks.push(buf);
|
|
729
|
+
}
|
|
730
|
+
signal?.throwIfAborted();
|
|
731
|
+
return Buffer.concat(chunks);
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
signal?.removeEventListener('abort', onAbort);
|
|
735
|
+
}
|
|
647
736
|
}
|
|
648
737
|
/** Avoid overwriting: append -1, -2, ... before the extension. */
|
|
649
738
|
async function uniquePath(path) {
|
package/lib/runtime.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Live settings, account-pool ownership, and independent tool/web watch cursors. */
|
|
2
|
+
import { type EmailConfig, type ResolvedEmailSettings } from './config.js';
|
|
3
|
+
import { EmailPool } from './mail-client.js';
|
|
4
|
+
import { type EmailSettingsValue } from './settings.js';
|
|
5
|
+
import type { EmailWatchResult } from './types.js';
|
|
6
|
+
export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'startIdleSweep' | 'dispose'>;
|
|
7
|
+
export interface EmailSettingsScope {
|
|
8
|
+
get(): unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface EmailRuntimeContext {
|
|
11
|
+
settings: {
|
|
12
|
+
register(namespace: string, schema: unknown, options: {
|
|
13
|
+
base: Partial<EmailSettingsValue>;
|
|
14
|
+
applies: 'live';
|
|
15
|
+
validate(value: unknown): void;
|
|
16
|
+
}): EmailSettingsScope;
|
|
17
|
+
describe?(): Array<{
|
|
18
|
+
ns: string;
|
|
19
|
+
user?: Partial<EmailSettingsValue>;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
effect(effect: () => () => void): unknown;
|
|
23
|
+
logger?: {
|
|
24
|
+
warn?(message: string): void;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface EmailRuntime {
|
|
28
|
+
readonly settingsScope: EmailSettingsScope;
|
|
29
|
+
getSettingsValue(): EmailSettingsValue;
|
|
30
|
+
getEffectiveSettings(): ResolvedEmailSettings;
|
|
31
|
+
getPool(): EmailClient;
|
|
32
|
+
watch(account: string, folder: string, limit: number, scope: string, signal?: AbortSignal): Promise<EmailWatchResult>;
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
/** Register live settings and own exactly one pool for their effective value. */
|
|
36
|
+
export declare function createEmailRuntime(ctx: EmailRuntimeContext, config: EmailConfig, createPool?: (settings: ResolvedEmailSettings) => EmailClient): EmailRuntime;
|
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Live settings, account-pool ownership, and independent tool/web watch cursors. */
|
|
2
|
+
import { clampInt, resolveEmailSettings } from './config.js';
|
|
3
|
+
import { EmailPool, messageOf } from './mail-client.js';
|
|
4
|
+
import { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
|
|
5
|
+
function fingerprintSettings(settings) {
|
|
6
|
+
return JSON.stringify({
|
|
7
|
+
accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
|
|
8
|
+
defaultAccount: settings.defaultAccount,
|
|
9
|
+
sendApproval: settings.sendApproval,
|
|
10
|
+
maxBodyChars: settings.maxBodyChars,
|
|
11
|
+
downloadDir: settings.downloadDir,
|
|
12
|
+
downloadDirExplicit: settings.downloadDirExplicit,
|
|
13
|
+
maxAttachmentBytes: settings.maxAttachmentBytes,
|
|
14
|
+
bodySearchFallback: settings.bodySearchFallback,
|
|
15
|
+
bodySearchLimit: settings.bodySearchLimit,
|
|
16
|
+
idleTimeoutMs: settings.idleTimeoutMs,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Register live settings and own exactly one pool for their effective value. */
|
|
20
|
+
export function createEmailRuntime(ctx, config, createPool = settings => new EmailPool(settings)) {
|
|
21
|
+
const settingsScope = ctx.settings.register(SETTINGS_NAMESPACE, EmailSettingsSchema, {
|
|
22
|
+
base: toSettingsBase(config),
|
|
23
|
+
applies: 'live',
|
|
24
|
+
validate: value => validateSettingsValue(value),
|
|
25
|
+
});
|
|
26
|
+
const getSettingsValue = () => settingsScope.get();
|
|
27
|
+
const getEffectiveSettings = () => {
|
|
28
|
+
// Form defaults must not overwrite row settings or provider presets.
|
|
29
|
+
const descriptor = (ctx.settings.describe?.() ?? []).find(row => row.ns === SETTINGS_NAMESPACE);
|
|
30
|
+
return resolveEmailSettings({ ...config, ...toEmailConfig(getSettingsValue(), descriptor?.user) });
|
|
31
|
+
};
|
|
32
|
+
let pool = null;
|
|
33
|
+
let poolFingerprint = '';
|
|
34
|
+
let disposed = false;
|
|
35
|
+
const getPool = () => {
|
|
36
|
+
if (disposed)
|
|
37
|
+
throw new Error('dsh-email 已卸载,不能继续使用邮箱连接池。');
|
|
38
|
+
const effective = getEffectiveSettings();
|
|
39
|
+
const fingerprint = fingerprintSettings(effective);
|
|
40
|
+
if (pool === null || fingerprint !== poolFingerprint) {
|
|
41
|
+
pool?.dispose();
|
|
42
|
+
pool = createPool(effective);
|
|
43
|
+
pool.startIdleSweep();
|
|
44
|
+
poolFingerprint = fingerprint;
|
|
45
|
+
}
|
|
46
|
+
return pool;
|
|
47
|
+
};
|
|
48
|
+
// Tool and browser watches share the implementation but never consume each
|
|
49
|
+
// other's cursor. The first call per scope/account/folder seeds a baseline.
|
|
50
|
+
const watchCursors = new Map();
|
|
51
|
+
const watch = async (account, folder, limit, scope, signal) => {
|
|
52
|
+
const capped = clampInt(limit, 20, 1, 100);
|
|
53
|
+
const result = await getPool().list(account, folder, 100, 0, true, undefined, undefined, signal);
|
|
54
|
+
const key = scope + '\u0000' + result.account + '\u0000' + result.folder;
|
|
55
|
+
const isFirst = !watchCursors.has(key);
|
|
56
|
+
const cursor = watchCursors.get(key) ?? 0;
|
|
57
|
+
const fresh = result.messages.filter(message => message.uid > cursor);
|
|
58
|
+
if (result.messages.length > 0) {
|
|
59
|
+
watchCursors.set(key, Math.max(cursor, ...result.messages.map(message => message.uid)));
|
|
60
|
+
}
|
|
61
|
+
else if (isFirst) {
|
|
62
|
+
watchCursors.set(key, 0);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
account: result.account,
|
|
66
|
+
folder: result.folder,
|
|
67
|
+
firstRun: isFirst,
|
|
68
|
+
newCount: isFirst ? 0 : fresh.length,
|
|
69
|
+
messages: (isFirst ? [] : fresh).slice(0, capped),
|
|
70
|
+
totalUnread: result.count,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const dispose = () => {
|
|
74
|
+
if (disposed)
|
|
75
|
+
return;
|
|
76
|
+
disposed = true;
|
|
77
|
+
pool?.dispose();
|
|
78
|
+
pool = null;
|
|
79
|
+
poolFingerprint = '';
|
|
80
|
+
watchCursors.clear();
|
|
81
|
+
};
|
|
82
|
+
// Missing credentials never prevent plugin registration. Account tools and
|
|
83
|
+
// email_health provide the configuration details when invoked.
|
|
84
|
+
try {
|
|
85
|
+
getPool();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
ctx.logger?.warn?.('[dsh-email] ' + messageOf(error, '未配置邮箱账号'));
|
|
89
|
+
}
|
|
90
|
+
ctx.effect(() => dispose);
|
|
91
|
+
return { settingsScope, getSettingsValue, getEffectiveSettings, getPool, watch, dispose };
|
|
92
|
+
}
|