dsh-email 0.11.0 → 0.13.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.
@@ -3,7 +3,7 @@ import nodemailer from 'nodemailer';
3
3
  import { mkdir, stat, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
6
- import { flattenAddresses, parseRawMessage, sanitizeFilename } from './parse.js';
6
+ import { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
7
7
  export class MailError extends Error {
8
8
  constructor(message) {
9
9
  super(message);
@@ -41,8 +41,8 @@ export function redactCredentials(text) {
41
41
  */
42
42
  export function imapAuthOf(cfg, accessToken) {
43
43
  return cfg.authKind === 'oauth2'
44
- ? { user: cfg.user, accessToken: accessToken ?? '' }
45
- : { user: cfg.user, pass: cfg.password };
44
+ ? { user: cfg.authUser, accessToken: accessToken ?? '' }
45
+ : { user: cfg.authUser, pass: cfg.authPassword };
46
46
  }
47
47
  /**
48
48
  * Nodemailer consumes an OAuth2 token through accessToken, not pass.
@@ -50,8 +50,8 @@ export function imapAuthOf(cfg, accessToken) {
50
50
  */
51
51
  export function smtpAuthOf(cfg, accessToken) {
52
52
  return cfg.authKind === 'oauth2'
53
- ? { type: 'OAuth2', user: cfg.user, accessToken: accessToken ?? '' }
54
- : { user: cfg.user, pass: cfg.password };
53
+ ? { type: 'OAuth2', user: cfg.authUser, accessToken: accessToken ?? '' }
54
+ : { user: cfg.authUser, pass: cfg.authPassword };
55
55
  }
56
56
  /** The message an OAuth2 account gets when the mailbox has to be logged into again. */
57
57
  export const OAUTH2_RELOGIN_MESSAGE = '邮箱登录失败:请到设置页重新登录(Microsoft 账号使用设备码登录,不使用授权码)';
@@ -118,6 +118,43 @@ export function selectAttachmentPart(readAttachments, parts, index) {
118
118
  const byTypeAndSize = parts.find(part => part.contentType === meta.contentType && Math.abs(part.size - meta.size) <= tolerance);
119
119
  return byTypeAndSize;
120
120
  }
121
+ /**
122
+ * Leaf text/* parts that can carry the message body, attachment parts excluded.
123
+ * This is what email_read / the search fallback fetch instead of the whole
124
+ * source: a 20 MiB attachment must not be downloaded just to read the text.
125
+ */
126
+ function collectTextParts(node, out = []) {
127
+ if (node === null || node === undefined || typeof node !== 'object')
128
+ return out;
129
+ // message/rfc822 整段是一封内嵌邮件(附件),它的正文分段不是本封的正文。
130
+ if (typeof node.type === 'string' && node.type.toLowerCase().startsWith('message/rfc822'))
131
+ return out;
132
+ const children = Array.isArray(node.childNodes) ? node.childNodes : [];
133
+ if (children.length === 0) {
134
+ const type = typeof node.type === 'string' ? node.type.toLowerCase() : '';
135
+ if (type.startsWith('text/') && node.disposition !== 'attachment') {
136
+ out.push({ key: node.part === undefined || node.part === null ? '1' : String(node.part), type });
137
+ }
138
+ return out;
139
+ }
140
+ for (const child of children)
141
+ collectTextParts(child, out);
142
+ return out;
143
+ }
144
+ /** The body parts worth fetching: text/plain first, text/html as the fallback. */
145
+ function selectBodyParts(node) {
146
+ const parts = collectTextParts(node);
147
+ const plain = parts.find(part => part.type === 'text/plain');
148
+ const html = parts.find(part => part.type === 'text/html');
149
+ return {
150
+ ...(plain !== undefined ? { plain } : {}),
151
+ ...(html !== undefined ? { html } : {}),
152
+ };
153
+ }
154
+ /** The From header: `user` is the visible address, `senderName` only labels it. */
155
+ function senderOf(cfg) {
156
+ return cfg.senderName === '' ? cfg.user : { name: cfg.senderName, address: cfg.user };
157
+ }
121
158
  /** Case-insensitive match of a query against subject/from/body text. */
122
159
  export function messageMatchesQuery(subject, fromText, body, query) {
123
160
  const q = query.toLowerCase();
@@ -142,10 +179,11 @@ function formatAddress(entry) {
142
179
  }
143
180
  function dedupeAddresses(entries, exclude) {
144
181
  const seen = new Set();
182
+ const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).map(a => a.trim().toLowerCase()).filter(a => a !== ''));
145
183
  const out = [];
146
184
  for (const entry of entries) {
147
185
  const addr = (entry.address ?? '').toLowerCase();
148
- if (addr === '' || addr === exclude || seen.has(addr))
186
+ if (addr === '' || excluded.has(addr) || seen.has(addr))
149
187
  continue;
150
188
  seen.add(addr);
151
189
  out.push(entry);
@@ -164,7 +202,7 @@ const FORWARD_MAX_CHARS = 4000;
164
202
  */
165
203
  export function buildReplyMessage(original, mode, selfAddress, text, forwardTo = '') {
166
204
  const fromText = original.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知发件人)';
167
- const self = selfAddress.toLowerCase();
205
+ const self = (Array.isArray(selfAddress) ? selfAddress : [selfAddress]).filter(a => a.trim() !== '');
168
206
  if (mode === 'forward') {
169
207
  const to = forwardTo.trim();
170
208
  if (to === '')
@@ -231,6 +269,11 @@ function listedFrom(envelope, size, hasAttachments) {
231
269
  hasAttachments,
232
270
  };
233
271
  }
272
+ /** email_attachment reuses the MIME index email_read already parsed; keep a few. */
273
+ const READ_CACHE_MAX = 16;
274
+ const READ_CACHE_TTL_MS = 10 * 60 * 1000;
275
+ /** Folder names change rarely; a short TTL keeps email_folders off the wire. */
276
+ const FOLDER_CACHE_TTL_MS = 60 * 1000;
234
277
  /**
235
278
  * One mailbox pool for the whole plugin: pooled IMAP connections per
236
279
  * account plus pooled SMTP transporters, with idle sweep and error eviction.
@@ -265,6 +308,72 @@ export class EmailPool {
265
308
  this.queues.set(name, next.then(() => undefined, () => undefined));
266
309
  return next;
267
310
  }
311
+ readCache = new Map();
312
+ folderCache = new Map();
313
+ /** UIDVALIDITY 变了以后同一 uid 可能指向另一封邮件,缓存键必须带上它。 */
314
+ uidValidityOf(client) {
315
+ const mailbox = client.mailbox;
316
+ return mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
317
+ }
318
+ /** Remember a parsed attachment index so email_attachment can skip the refetch. */
319
+ rememberRead(account, folder, uidValidity, uid, parsed) {
320
+ const key = account + '\u0000' + folder + '\u0000' + uidValidity + '\u0000' + uid;
321
+ this.readCache.delete(key);
322
+ this.readCache.set(key, { ...parsed, at: Date.now() });
323
+ while (this.readCache.size > READ_CACHE_MAX) {
324
+ const oldest = this.readCache.keys().next();
325
+ if (oldest.done === true)
326
+ break;
327
+ this.readCache.delete(oldest.value);
328
+ }
329
+ }
330
+ /**
331
+ * The attachment index for one message: the cached one when email_read already
332
+ * produced it, otherwise a fresh parse of the full source plus its bodyStructure.
333
+ */
334
+ async attachmentIndexOf(client, account, folder, uid, signal) {
335
+ const uidValidity = this.uidValidityOf(client);
336
+ const cached = this.recallRead(account, folder, uidValidity, uid);
337
+ if (cached !== undefined)
338
+ return cached;
339
+ // 附件索引只需要 MIME 结构:bodyStructure 已经给出每个附件的 part/名称/大小,
340
+ // 不必为了拿它先拉整封 source(第一次就下载附件的邮件也一样)。
341
+ const message = await client.fetchOne(uid, { uid: true, bodyStructure: true }, { uid: true });
342
+ if (message === false) {
343
+ throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
344
+ }
345
+ signal?.throwIfAborted();
346
+ if (message.bodyStructure !== undefined) {
347
+ const parts = collectAttachmentParts(message.bodyStructure);
348
+ const attachments = parts.map(part => ({ filename: part.filename, contentType: part.contentType, size: part.size, part: part.part }));
349
+ const parsed = { attachments, parts };
350
+ this.rememberRead(account, folder, uidValidity, uid, parsed);
351
+ return parsed;
352
+ }
353
+ // 服务器没给 bodyStructure:退回整封解析,行为和以前一样。
354
+ const full = message.source !== undefined
355
+ ? message
356
+ : await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
357
+ if (full === false || full.source === undefined) {
358
+ throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
359
+ }
360
+ const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
361
+ signal?.throwIfAborted();
362
+ const parsed = { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) };
363
+ this.rememberRead(account, folder, uidValidity, uid, parsed);
364
+ return parsed;
365
+ }
366
+ recallRead(account, folder, uidValidity, uid) {
367
+ const key = account + '\u0000' + folder + '\u0000' + uidValidity + '\u0000' + uid;
368
+ const hit = this.readCache.get(key);
369
+ if (hit === undefined)
370
+ return undefined;
371
+ if (Date.now() - hit.at > READ_CACHE_TTL_MS) {
372
+ this.readCache.delete(key);
373
+ return undefined;
374
+ }
375
+ return hit;
376
+ }
268
377
  async withImap(accountName, folder, run, readOnly = true, signal) {
269
378
  const name = this.resolveName(accountName);
270
379
  const cfg = this.account(name);
@@ -525,6 +634,33 @@ export class EmailPool {
525
634
  }
526
635
  }
527
636
  }
637
+ /**
638
+ * Download one MIME part through imapflow's decode pipeline: transfer
639
+ * encoding and charset are handled there, maxBytes caps what is fetched.
640
+ */
641
+ async downloadPartText(client, uid, part, maxBytes, signal) {
642
+ const dl = await client.download(uid, part.key, { uid: true, maxBytes });
643
+ signal?.throwIfAborted();
644
+ const buf = await collectStream(dl.content, maxBytes, signal);
645
+ signal?.throwIfAborted();
646
+ return buf.toString('utf8');
647
+ }
648
+ /**
649
+ * The message body without its attachments. undefined when the structure has
650
+ * no usable text part or the server refuses the part fetch, so the caller can
651
+ * fall back to the full-source path for that one message.
652
+ */
653
+ async bodyTextFromParts(client, uid, structure, maxBytes, signal) {
654
+ const { plain, html } = selectBodyParts(structure);
655
+ if (plain !== undefined) {
656
+ const text = await this.downloadPartText(client, uid, plain, maxBytes, signal);
657
+ if (text.trim() !== '' || html === undefined)
658
+ return text;
659
+ }
660
+ if (html !== undefined)
661
+ return stripHtml(await this.downloadPartText(client, uid, html, maxBytes, signal));
662
+ return undefined;
663
+ }
528
664
  async list(accountName, folder, limit, offset, unreadOnly, since, until, signal) {
529
665
  const name = this.resolveName(accountName);
530
666
  const cfg = this.account(name);
@@ -532,6 +668,8 @@ export class EmailPool {
532
668
  return this.withImap(name, folderName, async (client) => {
533
669
  const mailbox = client.mailbox;
534
670
  const total = mailbox === false ? 0 : mailbox.exists;
671
+ // imapflow types uidValidity as number | bigint; the wire format is 32-bit.
672
+ const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
535
673
  let scopeCount = total;
536
674
  let uids = [];
537
675
  const hasDateFilter = since !== undefined || until !== undefined;
@@ -557,10 +695,36 @@ export class EmailPool {
557
695
  uids.reverse();
558
696
  const window = uids.slice(offset, offset + limit);
559
697
  const messages = await this.fetchListed(client, window, signal);
560
- return { account: name, count: scopeCount, folder: folderName, messages };
698
+ return { account: name, count: scopeCount, folder: folderName, uidValidity, messages };
699
+ }, true, signal);
700
+ }
701
+ /**
702
+ * The uid index behind email_watch: SEARCH UNSEEN only, no envelopes and no
703
+ * bodies. The caller decides which uids it actually needs to report.
704
+ */
705
+ async unseenUids(accountName, folder, signal) {
706
+ const name = this.resolveName(accountName);
707
+ const cfg = this.account(name);
708
+ const folderName = folder || cfg.inboxFolder;
709
+ return this.withImap(name, folderName, async (client) => {
710
+ const mailbox = client.mailbox;
711
+ const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
712
+ const found = await client.search({ seen: false }, { uid: true });
713
+ signal?.throwIfAborted();
714
+ const uids = (found === false ? [] : found).slice().sort((a, b) => b - a);
715
+ return { account: name, folder: folderName, uidValidity, count: uids.length, uids };
561
716
  }, true, signal);
562
717
  }
563
- async search(accountName, query, folder, limit, since, until, signal) {
718
+ /** Fetch the envelopes for one uid batch: the rows email_watch will report. */
719
+ async fetchByUids(accountName, folder, uids, signal) {
720
+ if (uids.length === 0)
721
+ return [];
722
+ const name = this.resolveName(accountName);
723
+ const cfg = this.account(name);
724
+ const folderName = folder || cfg.inboxFolder;
725
+ return this.withImap(name, folderName, (client) => this.fetchListed(client, uids, signal), true, signal);
726
+ }
727
+ async search(accountName, query, folder, limit, offset, since, until, signal) {
564
728
  const name = this.resolveName(accountName);
565
729
  const cfg = this.account(name);
566
730
  const folderName = folder || cfg.inboxFolder;
@@ -586,20 +750,28 @@ export class EmailPool {
586
750
  signal?.throwIfAborted();
587
751
  const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => b - a);
588
752
  if (uids.length > 0) {
589
- const confirmed = await this.searchHits(client, uids, query, limit, signal);
753
+ // The sample has to cover the requested page (offset + limit) — the same
754
+ // window the fallback scan looks at — so one FETCH serves both the
755
+ // verification and the rows that are handed out.
756
+ const confirmed = await this.searchHits(client, uids, query, offset + limit, signal);
590
757
  if (confirmed.length > 0) {
591
758
  // The server's list holds up, so its size is reported as the match
592
759
  // count; only rows that were confirmed are ever handed out.
593
- return { account: name, query, count: uids.length, folder: folderName, messages: confirmed.slice(0, limit) };
760
+ return { account: name, query, count: uids.length, folder: folderName, offset, messages: confirmed.slice(offset, offset + limit) };
594
761
  }
595
762
  }
596
763
  // Nothing believable came back (empty answer, or hits that did not
597
764
  // survive verification): scan the newest messages locally instead.
598
765
  if (this.settings.bodySearchFallback) {
599
- const messages = await this.searchBodies(client, query, folderName, limit, since, until, signal);
600
- return { account: name, query, count: messages.length, folder: folderName, messages };
766
+ const messages = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
767
+ // 本地扫描不知道全文件夹有多少匹配,count 只是本页条数:用 countKind
768
+ // 让渲染说「本页 N 条(仅扫描最近 bodySearchLimit 封)」,不能说「共 N 条」。
769
+ return {
770
+ account: name, query, count: messages.length, folder: folderName, offset, messages,
771
+ countKind: 'scanned', scannedLimit: this.settings.bodySearchLimit,
772
+ };
601
773
  }
602
- return { account: name, query, count: 0, folder: folderName, messages: [] };
774
+ return { account: name, query, count: 0, folder: folderName, offset, messages: [] };
603
775
  }, true, signal);
604
776
  }
605
777
  /**
@@ -609,8 +781,8 @@ export class EmailPool {
609
781
  * the four fields the server was asked about. No body is downloaded here,
610
782
  * and uids the server made up simply return nothing.
611
783
  */
612
- async searchHits(client, uids, query, limit, signal) {
613
- const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, limit)));
784
+ async searchHits(client, uids, query, need, signal) {
785
+ const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, need)));
614
786
  signal?.throwIfAborted();
615
787
  const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
616
788
  signal?.throwIfAborted();
@@ -624,18 +796,19 @@ export class EmailPool {
624
796
  .sort((a, b) => b.uid - a.uid);
625
797
  }
626
798
  /** Client-side scan of the tail of the mailbox, newest first. */
627
- async searchBodies(client, query, folder, limit, since, until, signal) {
799
+ async searchBodies(client, query, folder, limit, offset, since, until, signal) {
628
800
  signal?.throwIfAborted();
629
801
  const mailbox = client.mailbox;
630
802
  const total = mailbox === false ? 0 : mailbox.exists;
631
803
  if (total === 0)
632
804
  return [];
633
805
  const start = Math.max(1, total - this.settings.bodySearchLimit + 1);
634
- const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, source: true, internalDate: true });
806
+ // 只取信封与 MIME 结构;正文在下面按 text/* 分段下载,附件不进正文匹配。
807
+ const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, internalDate: true });
635
808
  const out = [];
636
809
  for (const message of [...fetched].reverse()) {
637
810
  signal?.throwIfAborted();
638
- if (out.length >= limit)
811
+ if (out.length >= offset + limit)
639
812
  break;
640
813
  const receivedAt = message.internalDate ?? message.envelope?.date;
641
814
  if (since !== undefined && (receivedAt === undefined || receivedAt < since))
@@ -647,6 +820,7 @@ export class EmailPool {
647
820
  .map(flattenAddressText).join(' ');
648
821
  let body = '';
649
822
  if (message.source !== undefined) {
823
+ // 服务器多送了整封 source(测试/旧行为):直接解析,不必再多一次往返。
650
824
  try {
651
825
  const parsed = await parseRawMessage(message.source, 4096);
652
826
  signal?.throwIfAborted();
@@ -657,11 +831,20 @@ export class EmailPool {
657
831
  // 单封邮件解析失败不应中断整批回退扫描,继续用 subject/from/to/cc 匹配。
658
832
  }
659
833
  }
834
+ else if (message.bodyStructure !== undefined) {
835
+ try {
836
+ body = await this.bodyTextFromParts(client, message.uid, message.bodyStructure, 4096 * 4, signal) ?? '';
837
+ }
838
+ catch (error) {
839
+ signal?.throwIfAborted();
840
+ // 单封邮件分段下载失败不应中断整批回退扫描,继续用 subject/from/to/cc 匹配。
841
+ }
842
+ }
660
843
  if (messageMatchesQuery(subject, recipientSearchText, body, query)) {
661
844
  out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
662
845
  }
663
846
  }
664
- return out;
847
+ return out.slice(offset, offset + limit);
665
848
  }
666
849
  async fetchListed(client, uids, signal) {
667
850
  signal?.throwIfAborted();
@@ -678,12 +861,56 @@ export class EmailPool {
678
861
  const cfg = this.account(name);
679
862
  const folderName = folder || cfg.inboxFolder;
680
863
  return this.withImap(name, folderName, async (client) => {
681
- const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
682
- if (message === false || message.source === undefined) {
864
+ // 先只要信封与 MIME 结构,正文按 text/* 分段下载:带 20 MiB 附件的邮件
865
+ // 只为看正文时不再整封拉下来,附件元数据直接复用 bodyStructure。
866
+ const message = await client.fetchOne(uid, { uid: true, envelope: true, bodyStructure: true }, { uid: true });
867
+ if (message === false) {
683
868
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
684
869
  }
685
- const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
686
870
  signal?.throwIfAborted();
871
+ if (message.source === undefined && message.envelope !== undefined && message.bodyStructure !== undefined) {
872
+ try {
873
+ const text = await this.bodyTextFromParts(client, uid, message.bodyStructure, this.settings.maxBodyChars * 4 + 4096, signal);
874
+ if (text !== undefined) {
875
+ const limited = truncateText(text, this.settings.maxBodyChars);
876
+ const parts = collectAttachmentParts(message.bodyStructure);
877
+ const attachments = parts.map(part => ({
878
+ filename: part.filename,
879
+ contentType: part.contentType,
880
+ size: part.size,
881
+ part: part.part,
882
+ }));
883
+ this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments, parts });
884
+ const envelopeDate = message.envelope.date;
885
+ return {
886
+ account: name,
887
+ uid,
888
+ folder: folderName,
889
+ date: envelopeDate instanceof Date ? envelopeDate.toISOString() : '',
890
+ from: flattenAddresses(message.envelope.from),
891
+ to: flattenAddresses(message.envelope.to),
892
+ cc: flattenAddresses(message.envelope.cc),
893
+ subject: message.envelope.subject ?? '',
894
+ text: limited.text,
895
+ attachments,
896
+ truncated: limited.truncated,
897
+ };
898
+ }
899
+ }
900
+ catch (error) {
901
+ signal?.throwIfAborted();
902
+ // 分段读取失败(服务器拒绝该分段或结构异常):这一封退回整封解析。
903
+ }
904
+ }
905
+ const full = message.source !== undefined
906
+ ? message
907
+ : await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
908
+ if (full === false || full.source === undefined) {
909
+ throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
910
+ }
911
+ const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
912
+ signal?.throwIfAborted();
913
+ this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) });
687
914
  return { account: name, uid, folder: folderName, ...body };
688
915
  }, true, signal);
689
916
  }
@@ -746,17 +973,23 @@ export class EmailPool {
746
973
  async folders(accountName, subscribedOnly, signal) {
747
974
  const name = this.resolveName(accountName);
748
975
  return this.withImap(name, null, async (client) => {
749
- const list = await client.list();
750
- signal?.throwIfAborted();
751
- const folders = list
752
- .filter(row => !subscribedOnly || row.subscribed !== false)
753
- .map(row => ({
754
- name: row.name ?? row.path,
755
- path: row.path,
756
- specialUse: row.specialUse ?? '',
757
- subscribed: row.subscribed !== false,
758
- }));
759
- return { account: name, folders };
976
+ const cached = this.folderCache.get(name);
977
+ let rows;
978
+ if (cached !== undefined && Date.now() - cached.at < FOLDER_CACHE_TTL_MS) {
979
+ rows = cached.folders;
980
+ }
981
+ else {
982
+ const list = await client.list();
983
+ signal?.throwIfAborted();
984
+ rows = list.map(row => ({
985
+ name: row.name ?? row.path,
986
+ path: row.path,
987
+ specialUse: row.specialUse ?? '',
988
+ subscribed: row.subscribed !== false,
989
+ }));
990
+ this.folderCache.set(name, { at: Date.now(), folders: rows });
991
+ }
992
+ return { account: name, folders: rows.filter(row => !subscribedOnly || row.subscribed !== false) };
760
993
  }, true, signal);
761
994
  }
762
995
  async downloadAttachment(accountName, folder, uid, index, workspaceHint, signal) {
@@ -764,23 +997,19 @@ export class EmailPool {
764
997
  const cfg = this.account(name);
765
998
  const folderName = folder || cfg.inboxFolder;
766
999
  return this.withImap(name, folderName, async (client) => {
767
- const message = await client.fetchOne(uid, { uid: true, bodyStructure: true, source: true }, { uid: true });
768
- if (message === false || message.source === undefined) {
769
- throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '")');
770
- }
771
- // The mailparser list is authoritative for the index email_read showed;
772
- // the bodyStructure walk supplies the IMAP part to download.
773
- const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
774
- signal?.throwIfAborted();
775
- const parts = collectAttachmentParts(message.bodyStructure);
776
- if (body.attachments.length === 0)
1000
+ // The mailparser list is authoritative for the index email_read showed and
1001
+ // the bodyStructure walk supplies the IMAP part to download. email_read
1002
+ // already produced both in the usual read-then-download flow, so reuse that
1003
+ // instead of pulling the whole message — attachments included — again.
1004
+ const { attachments, parts } = await this.attachmentIndexOf(client, name, folderName, uid, signal);
1005
+ if (attachments.length === 0)
777
1006
  throw new MailError('该邮件没有附件');
778
- if (body.attachments[index] === undefined) {
779
- throw new MailError('附件序号 ' + index + ' 越界:共 ' + body.attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
1007
+ if (attachments[index] === undefined) {
1008
+ throw new MailError('附件序号 ' + index + ' 越界:共 ' + attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
780
1009
  }
781
- const att = selectAttachmentPart(body.attachments, parts, index);
1010
+ const att = selectAttachmentPart(attachments, parts, index);
782
1011
  if (att === undefined) {
783
- throw new MailError('附件 #' + index + '(' + body.attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
1012
+ throw new MailError('附件 #' + index + '(' + attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
784
1013
  }
785
1014
  if (att.size > this.settings.maxAttachmentBytes) {
786
1015
  throw new MailError('附件 "' + att.filename + '" 大小 ' + att.size + ' 字节,超过上限 maxAttachmentBytes=' + this.settings.maxAttachmentBytes);
@@ -788,7 +1017,7 @@ export class EmailPool {
788
1017
  const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
789
1018
  signal?.throwIfAborted();
790
1019
  const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
791
- const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? body.attachments[index].filename);
1020
+ const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? attachments[index].filename);
792
1021
  // Default the destination to the session workspace so the model can
793
1022
  // read the file back; an explicit downloadDir always wins.
794
1023
  const dir = this.settings.downloadDirExplicit
@@ -809,7 +1038,7 @@ export class EmailPool {
809
1038
  const cfg = this.account(name);
810
1039
  const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes, signal);
811
1040
  const info = await this.sendMail(name, cfg, {
812
- from: cfg.user,
1041
+ from: senderOf(cfg),
813
1042
  to,
814
1043
  cc,
815
1044
  subject,
@@ -838,10 +1067,13 @@ export class EmailPool {
838
1067
  const ids = extractMessageIds(message.source);
839
1068
  const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
840
1069
  signal?.throwIfAborted();
841
- 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);
1070
+ 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,
1071
+ // Both the visible address and the login are "me": a reply-all that
1072
+ // keeps either of them would mail the sender his own message.
1073
+ cfg.authUser === cfg.user ? cfg.user : [cfg.user, cfg.authUser], text, forwardTo);
842
1074
  }, true, signal);
843
1075
  const info = await this.sendMail(name, cfg, {
844
- from: cfg.user,
1076
+ from: senderOf(cfg),
845
1077
  to: built.to,
846
1078
  cc,
847
1079
  subject: built.subject,
package/lib/parse.js CHANGED
@@ -38,8 +38,10 @@ export function truncateText(text, maxChars) {
38
38
  if (text.length <= maxChars)
39
39
  return { text, truncated: false };
40
40
  const cut = text.slice(0, maxChars);
41
- const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' '), 0);
42
- return { text: cut.slice(0, lastBreak) + '\n\n…[正文过长,已截断,共 ' + text.length + ' 字符]', truncated: true };
41
+ const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' '));
42
+ // 窗口内没有空格/换行时必须硬切:否则 lastBreak 为 0,正文会被整段丢成空串。
43
+ const head = lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
44
+ return { text: head + '\n\n…[正文过长,已截断,共 ' + text.length + ' 字符]', truncated: true };
43
45
  }
44
46
  /**
45
47
  * Turn an untrusted attachment filename into a safe basename: no directory
package/lib/runtime.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type EmailConfig, type ResolvedEmailSettings } from './config.js';
3
3
  import { EmailPool } from './mail-client.js';
4
4
  import { type EmailSettingsValue } from './settings.js';
5
5
  import type { EmailWatchResult } from './types.js';
6
- export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'startIdleSweep' | 'dispose'>;
6
+ export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
7
7
  export interface EmailSettingsScope {
8
8
  get(): unknown;
9
9
  }
package/lib/runtime.js CHANGED
@@ -63,24 +63,42 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
63
63
  const watchCursors = new Map();
64
64
  const watch = async (account, folder, limit, scope, signal) => {
65
65
  const capped = clampInt(limit, 20, 1, 100);
66
- const result = await getPool().list(account, folder, 100, 0, true, undefined, undefined, signal);
67
- const key = scope + '\u0000' + result.account + '\u0000' + result.folder;
68
- const isFirst = !watchCursors.has(key);
69
- const cursor = watchCursors.get(key) ?? 0;
70
- const fresh = result.messages.filter(message => message.uid > cursor);
71
- if (result.messages.length > 0) {
72
- watchCursors.set(key, Math.max(cursor, ...result.messages.map(message => message.uid)));
66
+ const pool = getPool();
67
+ // 先只 SEARCH UNSEEN 拿 uid 列表与 totalUnread,再只为本轮要报告的最旧
68
+ // limit 封取信封:网页弹窗每 30 秒轮询一次,不能因为未读多就整批 FETCH。
69
+ const index = await pool.unseenUids(account, folder, signal);
70
+ const key = scope + '\u0000' + index.account + '\u0000' + index.folder;
71
+ const stored = watchCursors.get(key);
72
+ const uidValidity = typeof index.uidValidity === 'number' ? index.uidValidity : 0;
73
+ // A UIDVALIDITY change renumbers every message in the mailbox: keeping the
74
+ // old cursor would either report the whole folder as new or miss everything
75
+ // that renumbered below it. Re-seed the baseline instead and say so.
76
+ const reset = stored !== undefined && stored.uidValidity !== 0 && uidValidity !== 0 && stored.uidValidity !== uidValidity;
77
+ const isFirst = stored === undefined || reset;
78
+ const cursor = stored === undefined || reset ? 0 : stored.uid;
79
+ const fresh = isFirst ? [] : index.uids.filter(uid => uid > cursor);
80
+ // 后续每次只返回 fresh 中最旧的 limit 条,游标也只推进到这批的最大 uid:
81
+ // 窗口里更旧的新邮件留给下一轮,不能因为本次只返回 limit 条就被永久跳过。
82
+ const batch = fresh.slice(Math.max(0, fresh.length - capped));
83
+ const messages = batch.length > 0 ? await pool.fetchByUids(account, folder, batch, signal) : [];
84
+ if (isFirst) {
85
+ // 首次调用(或 UIDVALIDITY 重建)只落基线:游标取窗口最新一封,旧邮件不算新邮件。
86
+ watchCursors.set(key, {
87
+ uid: index.uids.length > 0 ? index.uids[0] : 0,
88
+ uidValidity,
89
+ });
73
90
  }
74
- else if (isFirst) {
75
- watchCursors.set(key, 0);
91
+ else if (batch.length > 0) {
92
+ watchCursors.set(key, { uid: Math.max(...batch), uidValidity });
76
93
  }
77
94
  return {
78
- account: result.account,
79
- folder: result.folder,
80
- firstRun: isFirst,
81
- newCount: isFirst ? 0 : fresh.length,
82
- messages: (isFirst ? [] : fresh).slice(0, capped),
83
- totalUnread: result.count,
95
+ account: index.account,
96
+ folder: index.folder,
97
+ firstRun: stored === undefined,
98
+ ...(reset ? { reset: true } : {}),
99
+ newCount: messages.length,
100
+ messages,
101
+ totalUnread: index.count,
84
102
  };
85
103
  };
86
104
  const dispose = () => {
@@ -350,6 +350,9 @@ export declare const watchSchema: {
350
350
  firstRun: {
351
351
  type: string;
352
352
  };
353
+ reset: {
354
+ type: string;
355
+ };
353
356
  newCount: {
354
357
  type: string;
355
358
  };