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.
@@ -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
- list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean, since?: Date, until?: Date): Promise<EmailListResult>;
79
- search(accountName: string | undefined, query: string, folder: string, limit: number, since?: Date, until?: Date): Promise<EmailSearchResult>;
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 {};
@@ -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 next = prev.then(task, task);
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
- async list(accountName, folder, limit, offset, unreadOnly, since, until) {
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
- const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes);
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
- await writeFile(dest, buf);
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.transporter(name, cfg).sendMail({
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.transporter(name, cfg).sendMail({
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
- for await (const chunk of stream) {
640
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
641
- total += buf.length;
642
- if (total > maxBytes)
643
- throw new MailError('附件超过上限 maxAttachmentBytes=' + maxBytes + ' 字节,下载中止');
644
- chunks.push(buf);
645
- }
646
- return Buffer.concat(chunks);
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) {
@@ -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
+ }