dsh-email 0.10.7 → 0.12.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.
@@ -2,6 +2,7 @@ import { ImapFlow } from 'imapflow';
2
2
  import nodemailer from 'nodemailer';
3
3
  import { mkdir, stat, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
5
6
  import { flattenAddresses, parseRawMessage, sanitizeFilename } from './parse.js';
6
7
  export class MailError extends Error {
7
8
  constructor(message) {
@@ -12,6 +13,66 @@ export class MailError extends Error {
12
13
  export function messageOf(error, fallback) {
13
14
  return error instanceof Error && error.message !== '' ? error.message : fallback;
14
15
  }
16
+ /**
17
+ * Replace anything credential-shaped in a server's own error text before it
18
+ * reaches a user.
19
+ *
20
+ * IMAP and SMTP servers routinely quote back the authentication string they
21
+ * rejected. For XOAUTH2 that string is `user=…\x01auth=Bearer <token>\x01\x01`,
22
+ * usually base64'd — so the raw message carries a live access token, and these
23
+ * messages are rendered in the settings panel, returned by the mail tools, and
24
+ * pasted into bug reports.
25
+ *
26
+ * Two shapes are masked: a JWT (three base64url segments, which is what every
27
+ * OAuth2 access token looks like) and a long base64 run (the quoted XOAUTH2
28
+ * blob). The replacement keeps the length so a report still says how big the
29
+ * thing was, without saying what it was.
30
+ */
31
+ export function redactCredentials(text) {
32
+ return text
33
+ .replace(/[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}/g, match => `<已隐去 ${match.length} 字符的令牌>`)
34
+ .replace(/[A-Za-z0-9+/]{40,}={0,2}/g, match => `<已隐去 ${match.length} 字符的凭据>`);
35
+ }
36
+ /**
37
+ * The IMAP `auth` block for one account. Pure so the shape the library
38
+ * receives is testable without a socket: an OAuth2 account authenticates with
39
+ * `accessToken` (imapflow then runs AUTHENTICATE XOAUTH2) and a password
40
+ * account with `pass`, exactly as before.
41
+ */
42
+ export function imapAuthOf(cfg, accessToken) {
43
+ return cfg.authKind === 'oauth2'
44
+ ? { user: cfg.authUser, accessToken: accessToken ?? '' }
45
+ : { user: cfg.authUser, pass: cfg.authPassword };
46
+ }
47
+ /**
48
+ * Nodemailer consumes an OAuth2 token through accessToken, not pass.
49
+ * Refresh remains owned by this plugin; no refresh credentials leave here.
50
+ */
51
+ export function smtpAuthOf(cfg, accessToken) {
52
+ return cfg.authKind === 'oauth2'
53
+ ? { type: 'OAuth2', user: cfg.authUser, accessToken: accessToken ?? '' }
54
+ : { user: cfg.authUser, pass: cfg.authPassword };
55
+ }
56
+ /** The message an OAuth2 account gets when the mailbox has to be logged into again. */
57
+ export const OAUTH2_RELOGIN_MESSAGE = '邮箱登录失败:请到设置页重新登录(Microsoft 账号使用设备码登录,不使用授权码)';
58
+ /**
59
+ * True for the errors both libraries report when the server rejects the
60
+ * credentials. An expired access token is indistinguishable from a wrong
61
+ * password at this level, so the connection retries once with a forced refresh
62
+ * before it believes the token is really dead.
63
+ */
64
+ export function looksLikeAuthFailure(error) {
65
+ const raw = messageOf(error, '').toLowerCase();
66
+ if (raw === '')
67
+ return false;
68
+ return raw.includes('authentication')
69
+ || raw.includes('authenticate')
70
+ || raw.includes('auth failed')
71
+ || raw.includes('login')
72
+ || raw.includes('command failed')
73
+ || raw.includes('invalid credentials')
74
+ || raw.includes('xoauth2');
75
+ }
15
76
  /** True when any bodyStructure node declares an attachment disposition. */
16
77
  function structureHasAttachment(node) {
17
78
  if (node === null || node === undefined || typeof node !== 'object')
@@ -57,6 +118,10 @@ export function selectAttachmentPart(readAttachments, parts, index) {
57
118
  const byTypeAndSize = parts.find(part => part.contentType === meta.contentType && Math.abs(part.size - meta.size) <= tolerance);
58
119
  return byTypeAndSize;
59
120
  }
121
+ /** The From header: `user` is the visible address, `senderName` only labels it. */
122
+ function senderOf(cfg) {
123
+ return cfg.senderName === '' ? cfg.user : { name: cfg.senderName, address: cfg.user };
124
+ }
60
125
  /** Case-insensitive match of a query against subject/from/body text. */
61
126
  export function messageMatchesQuery(subject, fromText, body, query) {
62
127
  const q = query.toLowerCase();
@@ -81,10 +146,11 @@ function formatAddress(entry) {
81
146
  }
82
147
  function dedupeAddresses(entries, exclude) {
83
148
  const seen = new Set();
149
+ const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).map(a => a.trim().toLowerCase()).filter(a => a !== ''));
84
150
  const out = [];
85
151
  for (const entry of entries) {
86
152
  const addr = (entry.address ?? '').toLowerCase();
87
- if (addr === '' || addr === exclude || seen.has(addr))
153
+ if (addr === '' || excluded.has(addr) || seen.has(addr))
88
154
  continue;
89
155
  seen.add(addr);
90
156
  out.push(entry);
@@ -103,7 +169,7 @@ const FORWARD_MAX_CHARS = 4000;
103
169
  */
104
170
  export function buildReplyMessage(original, mode, selfAddress, text, forwardTo = '') {
105
171
  const fromText = original.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知发件人)';
106
- const self = selfAddress.toLowerCase();
172
+ const self = (Array.isArray(selfAddress) ? selfAddress : [selfAddress]).filter(a => a.trim() !== '');
107
173
  if (mode === 'forward') {
108
174
  const to = forwardTo.trim();
109
175
  if (to === '')
@@ -170,6 +236,11 @@ function listedFrom(envelope, size, hasAttachments) {
170
236
  hasAttachments,
171
237
  };
172
238
  }
239
+ /** email_attachment reuses the MIME index email_read already parsed; keep a few. */
240
+ const READ_CACHE_MAX = 16;
241
+ const READ_CACHE_TTL_MS = 10 * 60 * 1000;
242
+ /** Folder names change rarely; a short TTL keeps email_folders off the wire. */
243
+ const FOLDER_CACHE_TTL_MS = 60 * 1000;
173
244
  /**
174
245
  * One mailbox pool for the whole plugin: pooled IMAP connections per
175
246
  * account plus pooled SMTP transporters, with idle sweep and error eviction.
@@ -204,17 +275,60 @@ export class EmailPool {
204
275
  this.queues.set(name, next.then(() => undefined, () => undefined));
205
276
  return next;
206
277
  }
278
+ readCache = new Map();
279
+ folderCache = new Map();
280
+ /** Remember a parsed attachment index so email_attachment can skip the refetch. */
281
+ rememberRead(account, folder, uid, parsed) {
282
+ const key = account + '\u0000' + folder + '\u0000' + uid;
283
+ this.readCache.delete(key);
284
+ this.readCache.set(key, { ...parsed, at: Date.now() });
285
+ while (this.readCache.size > READ_CACHE_MAX) {
286
+ const oldest = this.readCache.keys().next();
287
+ if (oldest.done === true)
288
+ break;
289
+ this.readCache.delete(oldest.value);
290
+ }
291
+ }
292
+ /**
293
+ * The attachment index for one message: the cached one when email_read already
294
+ * produced it, otherwise a fresh parse of the full source plus its bodyStructure.
295
+ */
296
+ async attachmentIndexOf(client, account, folder, uid, signal) {
297
+ const cached = this.recallRead(account, folder, uid);
298
+ if (cached !== undefined)
299
+ return cached;
300
+ const message = await client.fetchOne(uid, { uid: true, bodyStructure: true, source: true }, { uid: true });
301
+ if (message === false || message.source === undefined) {
302
+ throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
303
+ }
304
+ const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
305
+ signal?.throwIfAborted();
306
+ const parsed = { attachments: body.attachments, parts: collectAttachmentParts(message.bodyStructure) };
307
+ this.rememberRead(account, folder, uid, parsed);
308
+ return parsed;
309
+ }
310
+ recallRead(account, folder, uid) {
311
+ const key = account + '\u0000' + folder + '\u0000' + uid;
312
+ const hit = this.readCache.get(key);
313
+ if (hit === undefined)
314
+ return undefined;
315
+ if (Date.now() - hit.at > READ_CACHE_TTL_MS) {
316
+ this.readCache.delete(key);
317
+ return undefined;
318
+ }
319
+ return hit;
320
+ }
207
321
  async withImap(accountName, folder, run, readOnly = true, signal) {
208
322
  const name = this.resolveName(accountName);
209
323
  const cfg = this.account(name);
210
324
  return this.enqueue(name, () => this.imapRun(name, cfg, folder, readOnly, run, signal), signal);
211
325
  }
212
- createImap(cfg) {
326
+ createImap(auth, cfg) {
213
327
  const client = new ImapFlow({
214
328
  host: cfg.imap.host,
215
329
  port: cfg.imap.port,
216
330
  secure: cfg.imap.secure,
217
- auth: { user: cfg.user, pass: cfg.password },
331
+ auth,
218
332
  logger: false,
219
333
  connectionTimeout: cfg.imap.connectionTimeoutMs ?? 30000,
220
334
  greetingTimeout: 30000,
@@ -234,6 +348,54 @@ export class EmailPool {
234
348
  });
235
349
  return client;
236
350
  }
351
+ /**
352
+ * Dial and authenticate one fresh IMAP connection.
353
+ *
354
+ * A password account connects once. An OAuth2 account connects with a fresh
355
+ * access token and, when the server rejects it, refreshes once and tries
356
+ * again: a token that expired between the freshness check and the dial is
357
+ * indistinguishable from a wrong password at the socket, and guessing wrong
358
+ * would send the user through a browser login for nothing.
359
+ */
360
+ async connectImap(name, cfg, forceToken = false) {
361
+ const oauth2 = cfg.authKind === 'oauth2';
362
+ const attempt = async (token) => {
363
+ const client = this.createImap(imapAuthOf(cfg, token), cfg);
364
+ await client.connect();
365
+ return client;
366
+ };
367
+ let token;
368
+ if (oauth2) {
369
+ try {
370
+ token = await getFreshAccessToken(name, cfg, { force: forceToken });
371
+ }
372
+ catch (error) {
373
+ throw this.oauth2ErrorOf(error);
374
+ }
375
+ }
376
+ try {
377
+ return await attempt(token);
378
+ }
379
+ catch (error) {
380
+ if (!oauth2 || !looksLikeAuthFailure(error))
381
+ throw error;
382
+ try {
383
+ token = await getFreshAccessToken(name, cfg, { force: true });
384
+ }
385
+ catch (refreshError) {
386
+ throw this.oauth2ErrorOf(refreshError);
387
+ }
388
+ return await attempt(token);
389
+ }
390
+ }
391
+ /** The token store's own errors are already actionable; never dress them as IMAP failures. */
392
+ oauth2ErrorOf(error) {
393
+ if (error instanceof OAuth2Error)
394
+ return new MailError(error.message);
395
+ if (error instanceof MailError)
396
+ return error;
397
+ return new MailError(OAUTH2_RELOGIN_MESSAGE + '(' + redactCredentials(messageOf(error, '未知错误')) + ')');
398
+ }
237
399
  async imapRun(name, cfg, folder, readOnly, run, signal) {
238
400
  let entry = this.imaps.get(name);
239
401
  let activeClient = entry?.client;
@@ -252,11 +414,9 @@ export class EmailPool {
252
414
  if (entry === undefined || !entry.client.usable) {
253
415
  if (entry !== undefined)
254
416
  await this.evictImap(name);
255
- const client = this.createImap(cfg);
417
+ const client = await this.connectImap(name, cfg);
256
418
  activeClient = client;
257
419
  signal?.throwIfAborted();
258
- await client.connect();
259
- signal?.throwIfAborted();
260
420
  entry = { client, selected: null, selectedReadOnly: true, lastUsed: Date.now(), inUse: 0 };
261
421
  this.imaps.set(name, entry);
262
422
  }
@@ -279,7 +439,7 @@ export class EmailPool {
279
439
  catch (error) {
280
440
  await this.evictImap(name);
281
441
  signal?.throwIfAborted();
282
- throw this.normalizeImapError(error, folder);
442
+ throw this.normalizeImapError(error, folder, cfg.authKind);
283
443
  }
284
444
  finally {
285
445
  signal?.removeEventListener('abort', onAbort);
@@ -287,11 +447,14 @@ export class EmailPool {
287
447
  entry.inUse = Math.max(0, entry.inUse - 1);
288
448
  }
289
449
  }
290
- normalizeImapError(error, folder) {
450
+ normalizeImapError(error, folder, authKind = 'password') {
291
451
  const raw = messageOf(error, 'IMAP 操作失败');
292
452
  const lower = raw.toLowerCase();
293
453
  if (lower.includes('authentication') || lower.includes('login')) {
294
- return new MailError('邮箱登录失败:' + raw + '(请检查 user 与授权码)');
454
+ // An OAuth2 account has no 授权码 to check: the only fix is a new login.
455
+ return new MailError(authKind === 'oauth2'
456
+ ? OAUTH2_RELOGIN_MESSAGE + '(' + raw + ')'
457
+ : '邮箱登录失败:' + raw + '(请检查 user 与授权码)');
295
458
  }
296
459
  if (lower.includes('nonselect') || lower.includes('does not exist') || lower.includes('nonexistent')) {
297
460
  return new MailError('找不到邮箱文件夹 "' + (folder ?? '') + '":' + raw);
@@ -333,7 +496,12 @@ export class EmailPool {
333
496
  transporter.close();
334
497
  this.smtps.clear();
335
498
  }
336
- transporter(name, cfg) {
499
+ /**
500
+ * A pooled transporter for one account. The token is captured when the
501
+ * transporter is built; an OAuth2 token that turns out to be stale is
502
+ * re-minted in sendMail, which rebuilds the transporter.
503
+ */
504
+ transporter(name, cfg, accessToken) {
337
505
  let t = this.smtps.get(name);
338
506
  if (t === undefined) {
339
507
  t = nodemailer.createTransport({
@@ -341,7 +509,7 @@ export class EmailPool {
341
509
  host: cfg.smtp.host,
342
510
  port: cfg.smtp.port,
343
511
  secure: cfg.smtp.secure,
344
- auth: { user: cfg.user, pass: cfg.password },
512
+ auth: smtpAuthOf(cfg, accessToken),
345
513
  connectionTimeout: 30000,
346
514
  greetingTimeout: 10000,
347
515
  socketTimeout: 60000,
@@ -352,27 +520,62 @@ export class EmailPool {
352
520
  }
353
521
  return t;
354
522
  }
355
- /** Send through the pooled transporter while making cancellation close it. */
523
+ dropTransporter(name, transporter) {
524
+ if (this.smtps.get(name) === transporter)
525
+ this.smtps.delete(name);
526
+ transporter.close();
527
+ }
528
+ /**
529
+ * Send through the pooled transporter while making cancellation close it.
530
+ *
531
+ * An OAuth2 transporter carries a token that was minted when it was built,
532
+ * so a rejection is retried once against a freshly built one (and a fresh
533
+ * form of whatever stored token state exists). Password accounts keep the
534
+ * single attempt they always had.
535
+ */
356
536
  async sendMail(name, cfg, message, signal) {
357
537
  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();
538
+ const attempt = async (forceToken) => {
539
+ const token = cfg.authKind === 'oauth2' ? await getFreshAccessToken(name, cfg, { force: forceToken }) : undefined;
540
+ const transporter = this.transporter(name, cfg, token);
541
+ const onAbort = () => {
542
+ this.dropTransporter(name, transporter);
543
+ };
544
+ signal?.addEventListener('abort', onAbort, { once: true });
545
+ try {
546
+ const info = await transporter.sendMail(message);
547
+ signal?.throwIfAborted();
548
+ return info;
549
+ }
550
+ finally {
551
+ signal?.removeEventListener('abort', onAbort);
552
+ }
363
553
  };
364
- signal?.addEventListener('abort', onAbort, { once: true });
365
554
  try {
366
- const info = await transporter.sendMail(message);
367
- signal?.throwIfAborted();
368
- return info;
555
+ return await attempt(false);
369
556
  }
370
557
  catch (error) {
371
558
  signal?.throwIfAborted();
372
- throw error;
373
- }
374
- finally {
375
- signal?.removeEventListener('abort', onAbort);
559
+ if (cfg.authKind !== 'oauth2')
560
+ throw error;
561
+ // A pooled connection that already authenticated can fail for reasons no
562
+ // token can fix (a rejected recipient, a full mailbox). Only a credential
563
+ // rejection is worth a second, freshly-tokened attempt — and a token
564
+ // store that refused outright is reported as itself.
565
+ if (!looksLikeAuthFailure(error))
566
+ throw this.oauth2ErrorOf(error);
567
+ // The cached transporter holds the old token: it has to go, or the retry
568
+ // would reuse the very credential that was just refused.
569
+ const stale = this.smtps.get(name);
570
+ if (stale !== undefined)
571
+ this.dropTransporter(name, stale);
572
+ try {
573
+ return await attempt(true);
574
+ }
575
+ catch (retryError) {
576
+ signal?.throwIfAborted();
577
+ throw this.oauth2ErrorOf(retryError);
578
+ }
376
579
  }
377
580
  }
378
581
  async list(accountName, folder, limit, offset, unreadOnly, since, until, signal) {
@@ -382,6 +585,8 @@ export class EmailPool {
382
585
  return this.withImap(name, folderName, async (client) => {
383
586
  const mailbox = client.mailbox;
384
587
  const total = mailbox === false ? 0 : mailbox.exists;
588
+ // imapflow types uidValidity as number | bigint; the wire format is 32-bit.
589
+ const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
385
590
  let scopeCount = total;
386
591
  let uids = [];
387
592
  const hasDateFilter = since !== undefined || until !== undefined;
@@ -407,17 +612,21 @@ export class EmailPool {
407
612
  uids.reverse();
408
613
  const window = uids.slice(offset, offset + limit);
409
614
  const messages = await this.fetchListed(client, window, signal);
410
- return { account: name, count: scopeCount, folder: folderName, messages };
615
+ return { account: name, count: scopeCount, folder: folderName, uidValidity, messages };
411
616
  }, true, signal);
412
617
  }
413
- async search(accountName, query, folder, limit, since, until, signal) {
618
+ async search(accountName, query, folder, limit, offset, since, until, signal) {
414
619
  const name = this.resolveName(accountName);
415
620
  const cfg = this.account(name);
416
621
  const folderName = folder || cfg.inboxFolder;
417
622
  return this.withImap(name, folderName, async (client) => {
418
623
  // No nested OR and no TEXT search: several servers (QQ among them)
419
- // silently answer those with empty or match-everything results.
420
- // subject/from/to/cc searches unioned client-side behave well everywhere.
624
+ // silently answer those with empty results, and some answer with a
625
+ // non-empty list that has nothing to do with the query at all (QQ again:
626
+ // an impossible keyword still「matches」every uid in the folder). The
627
+ // server's hit list is therefore a hint, not an answer: confirm it
628
+ // against the envelopes before reporting anything, otherwise scan
629
+ // locally.
421
630
  const dateRange = {};
422
631
  if (since !== undefined)
423
632
  dateRange.since = since;
@@ -430,20 +639,50 @@ export class EmailPool {
430
639
  client.search({ cc: query, ...dateRange }, { uid: true }),
431
640
  ]);
432
641
  signal?.throwIfAborted();
433
- const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => a - b);
434
- uids.reverse();
435
- if (uids.length === 0 && this.settings.bodySearchFallback) {
436
- // Server-side search found nothing: fall back to a client-side scan of
437
- // the most recent messages (subject/from/body), capped for time.
438
- const messages = await this.searchBodies(client, query, folderName, limit, since, until, signal);
439
- return { account: name, query, count: messages.length, folder: folderName, messages };
440
- }
441
- const messages = await this.fetchListed(client, uids.slice(0, limit), signal);
442
- return { account: name, query, count: uids.length, folder: folderName, messages };
642
+ const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => b - a);
643
+ if (uids.length > 0) {
644
+ // The sample has to cover the requested page (offset + limit) — the same
645
+ // window the fallback scan looks at — so one FETCH serves both the
646
+ // verification and the rows that are handed out.
647
+ const confirmed = await this.searchHits(client, uids, query, offset + limit, signal);
648
+ if (confirmed.length > 0) {
649
+ // The server's list holds up, so its size is reported as the match
650
+ // count; only rows that were confirmed are ever handed out.
651
+ return { account: name, query, count: uids.length, folder: folderName, offset, messages: confirmed.slice(offset, offset + limit) };
652
+ }
653
+ }
654
+ // Nothing believable came back (empty answer, or hits that did not
655
+ // survive verification): scan the newest messages locally instead.
656
+ if (this.settings.bodySearchFallback) {
657
+ const messages = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
658
+ return { account: name, query, count: messages.length, folder: folderName, offset, messages };
659
+ }
660
+ return { account: name, query, count: 0, folder: folderName, offset, messages: [] };
443
661
  }, true, signal);
444
662
  }
663
+ /**
664
+ * Confirm server-side hits against the mailbox itself: fetch the envelopes
665
+ * of the newest candidates — the same window the body-scan fallback looks at
666
+ * — and keep only those that really carry the query in subject/from/to/cc,
667
+ * the four fields the server was asked about. No body is downloaded here,
668
+ * and uids the server made up simply return nothing.
669
+ */
670
+ async searchHits(client, uids, query, need, signal) {
671
+ const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, need)));
672
+ signal?.throwIfAborted();
673
+ const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
674
+ signal?.throwIfAborted();
675
+ return fetched
676
+ .filter(message => {
677
+ const envelope = message.envelope;
678
+ const addressText = [envelope?.from, envelope?.to, envelope?.cc].map(flattenAddressText).join(' ');
679
+ return messageMatchesQuery(envelope?.subject ?? '', addressText, '', query);
680
+ })
681
+ .map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
682
+ .sort((a, b) => b.uid - a.uid);
683
+ }
445
684
  /** Client-side scan of the tail of the mailbox, newest first. */
446
- async searchBodies(client, query, folder, limit, since, until, signal) {
685
+ async searchBodies(client, query, folder, limit, offset, since, until, signal) {
447
686
  signal?.throwIfAborted();
448
687
  const mailbox = client.mailbox;
449
688
  const total = mailbox === false ? 0 : mailbox.exists;
@@ -454,7 +693,7 @@ export class EmailPool {
454
693
  const out = [];
455
694
  for (const message of [...fetched].reverse()) {
456
695
  signal?.throwIfAborted();
457
- if (out.length >= limit)
696
+ if (out.length >= offset + limit)
458
697
  break;
459
698
  const receivedAt = message.internalDate ?? message.envelope?.date;
460
699
  if (since !== undefined && (receivedAt === undefined || receivedAt < since))
@@ -480,7 +719,7 @@ export class EmailPool {
480
719
  out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
481
720
  }
482
721
  }
483
- return out;
722
+ return out.slice(offset, offset + limit);
484
723
  }
485
724
  async fetchListed(client, uids, signal) {
486
725
  signal?.throwIfAborted();
@@ -497,12 +736,13 @@ export class EmailPool {
497
736
  const cfg = this.account(name);
498
737
  const folderName = folder || cfg.inboxFolder;
499
738
  return this.withImap(name, folderName, async (client) => {
500
- const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
739
+ const message = await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
501
740
  if (message === false || message.source === undefined) {
502
741
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
503
742
  }
504
743
  const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
505
744
  signal?.throwIfAborted();
745
+ this.rememberRead(name, folderName, uid, { attachments: body.attachments, parts: collectAttachmentParts(message.bodyStructure) });
506
746
  return { account: name, uid, folder: folderName, ...body };
507
747
  }, true, signal);
508
748
  }
@@ -565,17 +805,23 @@ export class EmailPool {
565
805
  async folders(accountName, subscribedOnly, signal) {
566
806
  const name = this.resolveName(accountName);
567
807
  return this.withImap(name, null, async (client) => {
568
- const list = await client.list();
569
- signal?.throwIfAborted();
570
- const folders = list
571
- .filter(row => !subscribedOnly || row.subscribed !== false)
572
- .map(row => ({
573
- name: row.name ?? row.path,
574
- path: row.path,
575
- specialUse: row.specialUse ?? '',
576
- subscribed: row.subscribed !== false,
577
- }));
578
- return { account: name, folders };
808
+ const cached = this.folderCache.get(name);
809
+ let rows;
810
+ if (cached !== undefined && Date.now() - cached.at < FOLDER_CACHE_TTL_MS) {
811
+ rows = cached.folders;
812
+ }
813
+ else {
814
+ const list = await client.list();
815
+ signal?.throwIfAborted();
816
+ rows = list.map(row => ({
817
+ name: row.name ?? row.path,
818
+ path: row.path,
819
+ specialUse: row.specialUse ?? '',
820
+ subscribed: row.subscribed !== false,
821
+ }));
822
+ this.folderCache.set(name, { at: Date.now(), folders: rows });
823
+ }
824
+ return { account: name, folders: rows.filter(row => !subscribedOnly || row.subscribed !== false) };
579
825
  }, true, signal);
580
826
  }
581
827
  async downloadAttachment(accountName, folder, uid, index, workspaceHint, signal) {
@@ -583,23 +829,19 @@ export class EmailPool {
583
829
  const cfg = this.account(name);
584
830
  const folderName = folder || cfg.inboxFolder;
585
831
  return this.withImap(name, folderName, async (client) => {
586
- const message = await client.fetchOne(uid, { uid: true, bodyStructure: true, source: true }, { uid: true });
587
- if (message === false || message.source === undefined) {
588
- throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '")');
589
- }
590
- // The mailparser list is authoritative for the index email_read showed;
591
- // the bodyStructure walk supplies the IMAP part to download.
592
- const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
593
- signal?.throwIfAborted();
594
- const parts = collectAttachmentParts(message.bodyStructure);
595
- if (body.attachments.length === 0)
832
+ // The mailparser list is authoritative for the index email_read showed and
833
+ // the bodyStructure walk supplies the IMAP part to download. email_read
834
+ // already produced both in the usual read-then-download flow, so reuse that
835
+ // instead of pulling the whole message — attachments included — again.
836
+ const { attachments, parts } = await this.attachmentIndexOf(client, name, folderName, uid, signal);
837
+ if (attachments.length === 0)
596
838
  throw new MailError('该邮件没有附件');
597
- if (body.attachments[index] === undefined) {
598
- throw new MailError('附件序号 ' + index + ' 越界:共 ' + body.attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
839
+ if (attachments[index] === undefined) {
840
+ throw new MailError('附件序号 ' + index + ' 越界:共 ' + attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
599
841
  }
600
- const att = selectAttachmentPart(body.attachments, parts, index);
842
+ const att = selectAttachmentPart(attachments, parts, index);
601
843
  if (att === undefined) {
602
- throw new MailError('附件 #' + index + '(' + body.attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
844
+ throw new MailError('附件 #' + index + '(' + attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
603
845
  }
604
846
  if (att.size > this.settings.maxAttachmentBytes) {
605
847
  throw new MailError('附件 "' + att.filename + '" 大小 ' + att.size + ' 字节,超过上限 maxAttachmentBytes=' + this.settings.maxAttachmentBytes);
@@ -607,7 +849,7 @@ export class EmailPool {
607
849
  const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
608
850
  signal?.throwIfAborted();
609
851
  const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
610
- const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? body.attachments[index].filename);
852
+ const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? attachments[index].filename);
611
853
  // Default the destination to the session workspace so the model can
612
854
  // read the file back; an explicit downloadDir always wins.
613
855
  const dir = this.settings.downloadDirExplicit
@@ -628,7 +870,7 @@ export class EmailPool {
628
870
  const cfg = this.account(name);
629
871
  const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes, signal);
630
872
  const info = await this.sendMail(name, cfg, {
631
- from: cfg.user,
873
+ from: senderOf(cfg),
632
874
  to,
633
875
  cc,
634
876
  subject,
@@ -657,10 +899,13 @@ export class EmailPool {
657
899
  const ids = extractMessageIds(message.source);
658
900
  const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
659
901
  signal?.throwIfAborted();
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);
902
+ 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,
903
+ // Both the visible address and the login are "me": a reply-all that
904
+ // keeps either of them would mail the sender his own message.
905
+ cfg.authUser === cfg.user ? cfg.user : [cfg.user, cfg.authUser], text, forwardTo);
661
906
  }, true, signal);
662
907
  const info = await this.sendMail(name, cfg, {
663
- from: cfg.user,
908
+ from: senderOf(cfg),
664
909
  to: built.to,
665
910
  cc,
666
911
  subject: built.subject,