react-native-email-imap-smtp 0.3.30 → 0.3.32

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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/margelo/nitro/emailimapsmtp/EmailImapSmtp.kt +199 -114
  3. package/lib/module/native.js +2 -1
  4. package/lib/module/native.js.map +1 -1
  5. package/lib/typescript/server/src/imapHandler.d.ts +5 -0
  6. package/lib/typescript/server/src/imapHandler.d.ts.map +1 -1
  7. package/lib/typescript/server/src/index.d.ts.map +1 -1
  8. package/lib/typescript/server/src/smtpHandler.d.ts.map +1 -1
  9. package/lib/typescript/src/EmailImapSmtp.nitro.d.ts +4 -0
  10. package/lib/typescript/src/EmailImapSmtp.nitro.d.ts.map +1 -1
  11. package/lib/typescript/src/native.d.ts.map +1 -1
  12. package/lib/typescript/src/types.d.ts +8 -0
  13. package/lib/typescript/src/types.d.ts.map +1 -1
  14. package/nitrogen/generated/android/c++/JConnectionConfig.hpp +7 -3
  15. package/nitrogen/generated/android/c++/JSmtpServerInfo.hpp +8 -3
  16. package/nitrogen/generated/android/c++/JVariant_ImapServerInfo_SmtpServerInfo.hpp +1 -0
  17. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/ConnectionConfig.kt +9 -4
  18. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SmtpServerInfo.kt +9 -4
  19. package/nitrogen/generated/ios/EmailImapSmtp-Swift-Cxx-Bridge.hpp +15 -15
  20. package/nitrogen/generated/ios/swift/ConnectionConfig.swift +20 -2
  21. package/nitrogen/generated/ios/swift/SmtpServerInfo.swift +19 -1
  22. package/nitrogen/generated/shared/c++/ConnectionConfig.hpp +6 -2
  23. package/nitrogen/generated/shared/c++/SmtpServerInfo.hpp +7 -2
  24. package/package.json +1 -1
  25. package/server/lib/imapHandler.d.ts +5 -0
  26. package/server/lib/imapHandler.d.ts.map +1 -1
  27. package/server/lib/imapHandler.js +247 -77
  28. package/server/lib/imapHandler.js.map +1 -1
  29. package/server/lib/index.d.ts.map +1 -1
  30. package/server/lib/index.js +24 -9
  31. package/server/lib/index.js.map +1 -1
  32. package/server/lib/smtpHandler.d.ts.map +1 -1
  33. package/server/lib/smtpHandler.js +76 -8
  34. package/server/lib/smtpHandler.js.map +1 -1
  35. package/server/package-lock.json +1 -0
  36. package/server/package.json +1 -0
  37. package/server/src/imapHandler.ts +273 -76
  38. package/server/src/imapflow.d.ts +31 -0
  39. package/server/src/index.ts +50 -16
  40. package/server/src/smtpHandler.ts +310 -242
  41. package/src/EmailImapSmtp.nitro.ts +4 -0
  42. package/src/native.ts +1 -0
  43. package/src/types.ts +8 -0
@@ -14,6 +14,8 @@ interface ImapSession {
14
14
  currentMailbox?: string;
15
15
  /** 连接配置(host/port/auth 等),用于断线后重建新实例 */
16
16
  config?: any;
17
+ /** 进行中的重连 Promise(并发请求共享,避免重连风暴) */
18
+ reconnecting?: Promise<void> | null;
17
19
  }
18
20
 
19
21
  const sessions = new Map<string, ImapSession>();
@@ -28,6 +30,13 @@ function getSession(sessionId: string): ImapSession {
28
30
  return s;
29
31
  }
30
32
 
33
+ /** 取会话并确保连接存活:若已断开则静默重建新 ImapFlow 并重选邮箱,再返回(静默重连入口) */
34
+ async function ensureSession(sessionId: string): Promise<ImapSession> {
35
+ const s = getSession(sessionId);
36
+ await ensureConnected(s);
37
+ return s;
38
+ }
39
+
31
40
  function flagsSetToArray(flags: Set<string> | undefined): string[] {
32
41
  if (!flags) return [];
33
42
  return Array.from(flags);
@@ -212,7 +221,7 @@ export async function disconnect(sessionId: string): Promise<void> {
212
221
  // ─── 列出邮箱 ──────────────────────────────────────────────
213
222
 
214
223
  export async function listMailboxes(sessionId: string): Promise<any[]> {
215
- const s = getSession(sessionId);
224
+ const s = await ensureSession(sessionId);
216
225
  const list = await s.client.list();
217
226
  return list.map((mbox) => ({
218
227
  name: mbox.name,
@@ -230,7 +239,7 @@ export async function selectMailbox(
230
239
  mailbox: string,
231
240
  readOnly: boolean
232
241
  ): Promise<any> {
233
- const s = getSession(sessionId);
242
+ const s = await ensureSession(sessionId);
234
243
  const lock = await s.client.getMailboxLock(mailbox);
235
244
  try {
236
245
  const mbox = await s.client.mailboxOpen(mailbox, { readOnly });
@@ -275,7 +284,7 @@ export async function selectMailbox(
275
284
  // ─── 刷新邮箱 ──────────────────────────────────────────────
276
285
 
277
286
  export async function refreshMailbox(sessionId: string): Promise<any> {
278
- const s = getSession(sessionId);
287
+ const s = await ensureSession(sessionId);
279
288
  const mailbox = s.currentMailbox ?? 'INBOX';
280
289
  const lock = await s.client.getMailboxLock(mailbox);
281
290
  try {
@@ -319,7 +328,7 @@ export async function refreshMailbox(sessionId: string): Promise<any> {
319
328
  // ─── 获取文件夹列表 ─────────────────────────────────────────
320
329
 
321
330
  export async function fetchFolders(sessionId: string): Promise<any[]> {
322
- const s = getSession(sessionId);
331
+ const s = await ensureSession(sessionId);
323
332
  const list = await s.client.list();
324
333
 
325
334
  const folders = list.map((mbox) => ({
@@ -365,7 +374,7 @@ export async function createFolder(
365
374
  sessionId: string,
366
375
  folderName: string
367
376
  ): Promise<void> {
368
- const s = getSession(sessionId);
377
+ const s = await ensureSession(sessionId);
369
378
  await s.client.mailboxCreate(folderName);
370
379
  }
371
380
 
@@ -373,7 +382,7 @@ export async function deleteFolder(
373
382
  sessionId: string,
374
383
  folderName: string
375
384
  ): Promise<void> {
376
- const s = getSession(sessionId);
385
+ const s = await ensureSession(sessionId);
377
386
  await s.client.mailboxDelete(folderName);
378
387
  }
379
388
 
@@ -382,7 +391,7 @@ export async function renameFolder(
382
391
  oldName: string,
383
392
  newName: string
384
393
  ): Promise<void> {
385
- const s = getSession(sessionId);
394
+ const s = await ensureSession(sessionId);
386
395
  await s.client.mailboxRename(oldName, newName);
387
396
  }
388
397
 
@@ -390,7 +399,7 @@ export async function subscribeFolder(
390
399
  sessionId: string,
391
400
  folderName: string
392
401
  ): Promise<void> {
393
- const s = getSession(sessionId);
402
+ const s = await ensureSession(sessionId);
394
403
  await s.client.mailboxSubscribe(folderName);
395
404
  }
396
405
 
@@ -398,22 +407,74 @@ export async function unsubscribeFolder(
398
407
  sessionId: string,
399
408
  folderName: string
400
409
  ): Promise<void> {
401
- const s = getSession(sessionId);
410
+ const s = await ensureSession(sessionId);
402
411
  await s.client.mailboxUnsubscribe(folderName);
403
412
  }
404
413
 
405
414
  // ─── 获取邮件 ──────────────────────────────────────────────
406
415
 
416
+ /** 从 bodyStructure 推导 text/plain 与 text/html 的 part key 与字符集(与 fetchAllAttachments 的 walk 同构) */
417
+ function findTextParts(
418
+ bs: any
419
+ ): {
420
+ plain?: { key: string; charset?: string };
421
+ html?: { key: string; charset?: string };
422
+ } {
423
+ const out: {
424
+ plain?: { key: string; charset?: string };
425
+ html?: { key: string; charset?: string };
426
+ } = {};
427
+ function walk(node: any, prefix: string) {
428
+ if (node.childNodes && node.childNodes.length > 0) {
429
+ node.childNodes.forEach((child: any, i: number) => {
430
+ walk(child, prefix ? `${prefix}.${i + 1}` : `${i + 1}`);
431
+ });
432
+ return;
433
+ }
434
+ const type = String(node.type || '').toLowerCase();
435
+ const sub = String(node.subType || '').toLowerCase();
436
+ const charset = node.parameters?.charset;
437
+ if (type === 'text' && sub === 'plain' && !out.plain) {
438
+ out.plain = { key: prefix || '1', charset };
439
+ } else if (type === 'text' && sub === 'html' && !out.html) {
440
+ out.html = { key: prefix || '1', charset };
441
+ }
442
+ }
443
+ walk(bs, '');
444
+ return out;
445
+ }
446
+
447
+ /** 解码 text part 内容(传输编码已由 imapflow 解码;这里处理字符集,GBK 等用 iconv-lite) */
448
+ function decodePartText(buf: Buffer, charset?: string): string {
449
+ const cs = String(charset || 'utf-8').toLowerCase().replace(/"/g, '').trim();
450
+ try {
451
+ const iconv = require('iconv-lite') as any;
452
+ if (iconv.encodingExists(cs)) return iconv.decode(buf, cs);
453
+ } catch {
454
+ /* 降级到 Buffer.toString */
455
+ }
456
+ try {
457
+ return buf.toString(cs as BufferEncoding);
458
+ } catch {
459
+ return buf.toString('utf-8');
460
+ }
461
+ }
462
+
407
463
  export async function fetchEmails(
408
464
  sessionId: string,
409
465
  options: any
410
466
  ): Promise<any[]> {
411
- const s = getSession(sessionId);
467
+ const s = await ensureSession(sessionId);
412
468
  const mailbox = options.mailbox ?? 'INBOX';
413
469
 
414
470
  const lock = await s.client.getMailboxLock(mailbox);
415
471
  try {
416
- const mbox = await s.client.mailboxOpen(mailbox);
472
+ // 已选中同一邮箱时跳过 SELECT,避免重复往返
473
+ const alreadySelected =
474
+ s.client.mailbox && s.client.mailbox.path === mailbox;
475
+ const mbox = alreadySelected
476
+ ? (s.client.mailbox as any)
477
+ : await s.client.mailboxOpen(mailbox);
417
478
  s.currentMailbox = mailbox;
418
479
  const total = mbox.exists ?? 0;
419
480
 
@@ -456,7 +517,8 @@ export async function fetchEmails(
456
517
  range = { ...range, ...df };
457
518
  }
458
519
 
459
- // 构建 fetch fields
520
+ // 构建 fetch fields(默认不拉全文;正文用第二段只取 text 部分,跳过附件下载)
521
+ const needBody = options.fetchBody !== false;
460
522
  const fields: any = {
461
523
  uid: true,
462
524
  flags: true,
@@ -466,22 +528,67 @@ export async function fetchEmails(
466
528
  internalDate: true,
467
529
  headers: true,
468
530
  };
469
- if (options.fetchBody !== false) {
470
- fields.source = true;
471
- }
472
531
 
473
- const messages: any[] = [];
532
+ // 第一段:元数据(含 bodyStructure),随后推导正文 part key
533
+ const entries: any[] = [];
534
+ const byUid = new Map<number, any>();
474
535
  let seq = 0;
475
-
536
+ let textParts: {
537
+ plain?: { key: string; charset?: string };
538
+ html?: { key: string; charset?: string };
539
+ } | null = null;
476
540
  for await (const msg of s.client.fetch(range, fields, fetchOpts)) {
477
541
  seq++;
478
- let body: { text?: string; html?: string } | undefined;
479
- if (msg.source) {
480
- body = await parseSource(msg.source);
542
+ const entry = { msg, seq };
543
+ entries.push(entry);
544
+ byUid.set(msg.uid, entry);
545
+ if (needBody && !textParts && msg.bodyStructure) {
546
+ textParts = findTextParts(msg.bodyStructure);
481
547
  }
482
- messages.push(buildEmailObject(msg, seq, body));
483
548
  }
484
549
 
550
+ // 第二段:只取 text/plain 与 text/html 正文(不下载附件)
551
+ if (needBody && textParts && (textParts.plain || textParts.html)) {
552
+ const bodyParts: any[] = [];
553
+ if (textParts.plain) bodyParts.push({ key: textParts.plain.key });
554
+ if (textParts.html) bodyParts.push({ key: textParts.html.key });
555
+ for await (const b of s.client.fetch(
556
+ range,
557
+ { uid: true, bodyParts },
558
+ fetchOpts
559
+ )) {
560
+ const entry = byUid.get(b.uid);
561
+ if (entry && b.bodyParts) {
562
+ entry.bodyPartsMap = b.bodyParts;
563
+ }
564
+ }
565
+ }
566
+
567
+ const messages = entries.map(({ msg, seq: seqNo, bodyPartsMap }: any) => {
568
+ let body: { text?: string; html?: string } | undefined;
569
+ if (needBody && bodyPartsMap && textParts) {
570
+ const getPart = (key: string) =>
571
+ bodyPartsMap.get(key) ?? bodyPartsMap.get(key.toUpperCase());
572
+ if (textParts.plain) {
573
+ const buf = getPart(textParts.plain.key);
574
+ if (buf)
575
+ body = {
576
+ ...(body ?? {}),
577
+ text: decodePartText(buf, textParts.plain.charset),
578
+ };
579
+ }
580
+ if (textParts.html) {
581
+ const buf = getPart(textParts.html.key);
582
+ if (buf)
583
+ body = {
584
+ ...(body ?? {}),
585
+ html: decodePartText(buf, textParts.html.charset),
586
+ };
587
+ }
588
+ }
589
+ return buildEmailObject(msg, seqNo, body);
590
+ });
591
+
485
592
  messages.sort((a, b) => b.uid - a.uid);
486
593
  return messages;
487
594
  } finally {
@@ -495,7 +602,7 @@ export async function fetchEmailByUID(
495
602
  sessionId: string,
496
603
  uid: number
497
604
  ): Promise<any | null> {
498
- const s = getSession(sessionId);
605
+ const s = await ensureSession(sessionId);
499
606
  for await (const msg of s.client.fetch(
500
607
  { uid: String(uid) },
501
608
  {
@@ -522,12 +629,17 @@ export async function searchEmails(
522
629
  sessionId: string,
523
630
  criteria: any
524
631
  ): Promise<any[]> {
525
- const s = getSession(sessionId);
632
+ const s = await ensureSession(sessionId);
526
633
  const mailbox = criteria.mailbox ?? 'INBOX';
527
634
 
528
635
  const lock = await s.client.getMailboxLock(mailbox);
529
636
  try {
530
- await s.client.mailboxOpen(mailbox);
637
+ // 已选中同一邮箱时跳过 SELECT
638
+ const alreadySelected =
639
+ s.client.mailbox && s.client.mailbox.path === mailbox;
640
+ if (!alreadySelected) {
641
+ await s.client.mailboxOpen(mailbox);
642
+ }
531
643
 
532
644
  const searchQuery: SearchObject = {};
533
645
 
@@ -576,6 +688,8 @@ export async function searchEmails(
576
688
  uids = uids.slice(0, criteria.limit);
577
689
  }
578
690
 
691
+ // 只取元数据 + 只拉 text 正文(不下载附件),与 fetchEmails 一致
692
+ const needBody = criteria.fetchBody !== false;
579
693
  const fields: any = {
580
694
  uid: true,
581
695
  flags: true,
@@ -585,20 +699,71 @@ export async function searchEmails(
585
699
  internalDate: true,
586
700
  headers: true,
587
701
  };
588
- if (criteria.fetchBody !== false) fields.source = true;
589
702
 
590
- const messages: any[] = [];
703
+ // 第一段:元数据(含 bodyStructure),随后推导正文 part key
704
+ const entries: any[] = [];
705
+ const byUid = new Map<number, any>();
591
706
  let seq = 0;
707
+ let textParts: {
708
+ plain?: { key: string; charset?: string };
709
+ html?: { key: string; charset?: string };
710
+ } | null = null;
592
711
  for await (const msg of s.client.fetch(
593
712
  { uid: uids.join(',') as any },
594
713
  fields,
595
714
  { uid: true }
596
715
  )) {
597
716
  seq++;
598
- const body = msg.source ? await parseSource(msg.source) : undefined;
599
- messages.push(buildEmailObject(msg, seq, body));
717
+ const entry = { msg, seq };
718
+ entries.push(entry);
719
+ byUid.set(msg.uid, entry);
720
+ if (needBody && !textParts && msg.bodyStructure) {
721
+ textParts = findTextParts(msg.bodyStructure);
722
+ }
600
723
  }
601
724
 
725
+ // 第二段:只取 text/plain 与 text/html 正文(不下载附件)
726
+ if (needBody && textParts && (textParts.plain || textParts.html)) {
727
+ const bodyParts: any[] = [];
728
+ if (textParts.plain) bodyParts.push({ key: textParts.plain.key });
729
+ if (textParts.html) bodyParts.push({ key: textParts.html.key });
730
+ for await (const b of s.client.fetch(
731
+ { uid: uids.join(',') as any },
732
+ { uid: true, bodyParts },
733
+ { uid: true }
734
+ )) {
735
+ const entry = byUid.get(b.uid);
736
+ if (entry && b.bodyParts) {
737
+ entry.bodyPartsMap = b.bodyParts;
738
+ }
739
+ }
740
+ }
741
+
742
+ const messages = entries.map(({ msg, seq: seqNo, bodyPartsMap }: any) => {
743
+ let body: { text?: string; html?: string } | undefined;
744
+ if (needBody && bodyPartsMap && textParts) {
745
+ const getPart = (key: string) =>
746
+ bodyPartsMap.get(key) ?? bodyPartsMap.get(key.toUpperCase());
747
+ if (textParts.plain) {
748
+ const buf = getPart(textParts.plain.key);
749
+ if (buf)
750
+ body = {
751
+ ...(body ?? {}),
752
+ text: decodePartText(buf, textParts.plain.charset),
753
+ };
754
+ }
755
+ if (textParts.html) {
756
+ const buf = getPart(textParts.html.key);
757
+ if (buf)
758
+ body = {
759
+ ...(body ?? {}),
760
+ html: decodePartText(buf, textParts.html.charset),
761
+ };
762
+ }
763
+ }
764
+ return buildEmailObject(msg, seqNo, body);
765
+ });
766
+
602
767
  return messages;
603
768
  } finally {
604
769
  lock.release();
@@ -612,13 +777,12 @@ export async function markAsRead(
612
777
  uids: number[],
613
778
  silent: boolean
614
779
  ): Promise<void> {
615
- const s = getSession(sessionId);
616
- for (const uid of uids) {
617
- await s.client.messageFlagsAdd({ uid: String(uid) }, ['\\Seen'], {
618
- silent,
619
- uid: true,
620
- });
621
- }
780
+ const s = await ensureSession(sessionId);
781
+ // 批量:一条 range 命令完成所有 UID,避免逐封往返
782
+ await s.client.messageFlagsAdd({ uid: uids.join(',') }, ['\\Seen'], {
783
+ silent,
784
+ uid: true,
785
+ });
622
786
  }
623
787
 
624
788
  export async function markAsFlagged(
@@ -626,17 +790,12 @@ export async function markAsFlagged(
626
790
  uids: number[],
627
791
  flagged: boolean
628
792
  ): Promise<void> {
629
- const s = getSession(sessionId);
630
- for (const uid of uids) {
631
- if (flagged) {
632
- await s.client.messageFlagsAdd({ uid: String(uid) }, ['\\Flagged'], {
633
- uid: true,
634
- });
635
- } else {
636
- await s.client.messageFlagsRemove({ uid: String(uid) }, ['\\Flagged'], {
637
- uid: true,
638
- });
639
- }
793
+ const s = await ensureSession(sessionId);
794
+ const range = { uid: uids.join(',') };
795
+ if (flagged) {
796
+ await s.client.messageFlagsAdd(range, ['\\Flagged'], { uid: true });
797
+ } else {
798
+ await s.client.messageFlagsRemove(range, ['\\Flagged'], { uid: true });
640
799
  }
641
800
  }
642
801
 
@@ -645,12 +804,10 @@ export async function moveEmails(
645
804
  uids: number[],
646
805
  destinationMailbox: string
647
806
  ): Promise<void> {
648
- const s = getSession(sessionId);
649
- for (const uid of uids) {
650
- await s.client.messageMove({ uid: String(uid) }, destinationMailbox, {
651
- uid: true,
652
- });
653
- }
807
+ const s = await ensureSession(sessionId);
808
+ await s.client.messageMove({ uid: uids.join(',') }, destinationMailbox, {
809
+ uid: true,
810
+ });
654
811
  }
655
812
 
656
813
  export async function copyEmails(
@@ -658,34 +815,32 @@ export async function copyEmails(
658
815
  uids: number[],
659
816
  destinationMailbox: string
660
817
  ): Promise<void> {
661
- const s = getSession(sessionId);
662
- for (const uid of uids) {
663
- await s.client.messageCopy({ uid: String(uid) }, destinationMailbox, {
664
- uid: true,
665
- });
666
- }
818
+ const s = await ensureSession(sessionId);
819
+ await s.client.messageCopy({ uid: uids.join(',') }, destinationMailbox, {
820
+ uid: true,
821
+ });
667
822
  }
668
823
 
669
824
  export async function deleteEmails(
670
825
  sessionId: string,
671
826
  uids: number[]
672
827
  ): Promise<any> {
673
- const s = getSession(sessionId);
674
- const success: number[] = [];
675
- const failed: number[] = [];
676
- for (const uid of uids) {
677
- try {
678
- await s.client.messageDelete({ uid: String(uid) }, { uid: true });
679
- success.push(uid);
680
- } catch {
681
- failed.push(uid);
682
- }
828
+ const s = await ensureSession(sessionId);
829
+ // 批量:一次 messageDelete 完成;失败则整批计入 failed(不再逐封往返)
830
+ try {
831
+ await s.client.messageDelete({ uid: uids.join(',') }, { uid: true });
832
+ return { success: uids, failed: [] };
833
+ } catch (err: any) {
834
+ return {
835
+ success: [],
836
+ failed: uids,
837
+ error: err?.message ?? 'Delete failed',
838
+ };
683
839
  }
684
- return { success, failed };
685
840
  }
686
841
 
687
842
  export async function expunge(sessionId: string): Promise<void> {
688
- const s = getSession(sessionId);
843
+ const s = await ensureSession(sessionId);
689
844
  const mailbox = s.currentMailbox ?? 'INBOX';
690
845
  const lock = await s.client.getMailboxLock(mailbox);
691
846
  try {
@@ -705,12 +860,23 @@ export async function expunge(sessionId: string): Promise<void> {
705
860
  * ⚠️ 不要用 s.client.connected 判断(该属性不存在,永远是 undefined),
706
861
  * 要用 isClosed + state。
707
862
  */
708
- async function ensureConnected(s: any): Promise<void> {
863
+ async function ensureConnected(s: ImapSession): Promise<void> {
709
864
  const client = s.client;
710
865
  const alive = !client.isClosed && client.state !== client.states.LOGOUT;
711
866
  if (alive) return;
712
867
 
713
- // 连接已关闭 → 重建新实例
868
+ // 连接已关闭 → 重建新实例。并发安全:多个请求同时发现断开时
869
+ // 共享同一个重连 Promise(s.reconnecting),只真正重连一次,避免重连风暴。
870
+ if (s.reconnecting) {
871
+ return s.reconnecting;
872
+ }
873
+ s.reconnecting = doReconnect(s).finally(() => {
874
+ s.reconnecting = null;
875
+ });
876
+ return s.reconnecting;
877
+ }
878
+
879
+ async function doReconnect(s: ImapSession): Promise<void> {
714
880
  if (!s.config) {
715
881
  throw new Error('IMAP connection lost, but no config to reconnect');
716
882
  }
@@ -731,6 +897,15 @@ async function ensureConnected(s: any): Promise<void> {
731
897
  logger: false,
732
898
  });
733
899
  await newClient.connect();
900
+ // 重连期间若会话已被显式断开,则放弃这条新连接
901
+ if (!s.connected) {
902
+ try {
903
+ await newClient.logout();
904
+ } catch {
905
+ /* ignore */
906
+ }
907
+ return;
908
+ }
734
909
  s.client = newClient;
735
910
  s.connected = true;
736
911
  // 重建后需重新选中原邮箱(download/fetch 依赖当前 mailbox)
@@ -740,6 +915,26 @@ async function ensureConnected(s: any): Promise<void> {
740
915
  console.log('[ensureConnected] reconnected, mailbox:', s.currentMailbox);
741
916
  }
742
917
 
918
+ // ─── 断线判定(供路由层决定是否自动重试)──────────────────
919
+
920
+ /** 判断错误是否为连接断开类(message + code),供路由层决定是否重连重试 */
921
+ export function isConnectionError(err: any): boolean {
922
+ if (!err) return false;
923
+ const msg = String(err?.message ?? '').toLowerCase();
924
+ const code = String(err?.code ?? '').toLowerCase();
925
+ const re =
926
+ /connection closed|connection lost|disconnected|socket closed|socket hang up|econnreset|etimedout|econnrefused|broken pipe|server closed|closed connection|network error|operation timed out/i;
927
+ return re.test(msg) || re.test(code);
928
+ }
929
+
930
+ /** 会话客户端是否已死(补充判断:message 未命中但客户端确实断了,也应重连重试)。
931
+ * 会话不存在或已被显式断开 → 返回 false(不该自动重连)。 */
932
+ export function isSessionClosed(sessionId: string): boolean {
933
+ const s = sessions.get(sessionId);
934
+ if (!s || !s.connected) return false;
935
+ return s.client.isClosed || s.client.state === s.client.states.LOGOUT;
936
+ }
937
+
743
938
  /** 给 Promise 加超时,避免死连接上无限挂起 */
744
939
  function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
745
940
  return new Promise<T>((resolve, reject) => {
@@ -763,8 +958,10 @@ export async function fetchAttachment(
763
958
  // 同时降低被 20s 超时误判的风险。不要设到接近无上限——imapflow 靠
764
959
  // 「块是否被填满」(chunk.length >= chunkSize) 判断是否还有更多,
765
960
  // 服务器若截断超大单次响应,会被误判为已结束 → 附件被静默截断。
961
+ // 分块大小默认 1MB,可由连接配置 downloadChunkSize 覆盖(与 Android 对齐)
962
+ const chunkSize = s.config?.downloadChunkSize ?? 1024 * 1024;
766
963
  const download = await withTimeout(
767
- s.client.download(String(uid), partId, { uid: true, chunkSize: 1024 * 1024 }),
964
+ s.client.download(String(uid), partId, { uid: true, chunkSize }),
768
965
  20000,
769
966
  `download ${partId}`,
770
967
  );
@@ -805,7 +1002,7 @@ export async function fetchAllAttachments(
805
1002
  sessionId: string,
806
1003
  uid: number
807
1004
  ): Promise<any[]> {
808
- const s = getSession(sessionId);
1005
+ const s = await ensureSession(sessionId);
809
1006
 
810
1007
  // 确保已选中 mailbox(download() 依赖 this.mailbox,未选中会静默返回 {})
811
1008
  const mailbox = s.currentMailbox ?? 'INBOX';
@@ -956,7 +1153,7 @@ export function stopIdle(sessionId: string): void {
956
1153
  // ─── Quota ─────────────────────────────────────────────────
957
1154
 
958
1155
  export async function getQuota(sessionId: string, root?: string): Promise<any> {
959
- const s = getSession(sessionId);
1156
+ const s = await ensureSession(sessionId);
960
1157
  const quota = await s.client.getQuota(root);
961
1158
  if (!quota) {
962
1159
  return {
@@ -984,7 +1181,7 @@ export async function appendMessage(
984
1181
  rawMimeData: string,
985
1182
  flags: string[] | null
986
1183
  ): Promise<any> {
987
- const s = getSession(sessionId);
1184
+ const s = await ensureSession(sessionId);
988
1185
  const content = Buffer.from(rawMimeData, 'base64');
989
1186
 
990
1187
  // APPENDLIMIT(RFC 7889):服务器接受的最大单封邮件字节数。
@@ -0,0 +1,31 @@
1
+ // ============================================================
2
+ // imapflow 类型补充声明(module augmentation)
3
+ // ============================================================
4
+ //
5
+ // imapflow v1.5.0 的官方 .d.ts(lib/imap-flow.d.ts)未声明以下运行时属性,
6
+ // 但源码 lib/imap-flow.js 中确实存在(this.isClosed / this.state / this.states):
7
+ // - isClosed: boolean 连接已关闭时为 true
8
+ // - states: { ... } 连接状态常量枚举
9
+ // - state: number 当前连接状态值(对应 states 中的常量)
10
+ //
11
+ // 通过 declare module 合并到 ImapFlow 类上,避免用 any 绕过类型检查。
12
+ // 不要删除本文件:断线检测(ensureConnected / isSessionClosed)依赖这些字段。
13
+ // ============================================================
14
+
15
+ import 'imapflow';
16
+
17
+ declare module 'imapflow' {
18
+ interface ImapFlow {
19
+ /** 连接是否已关闭(服务器断开后为 true) */
20
+ isClosed: boolean;
21
+ /** 当前连接状态值(对应 states 中的常量) */
22
+ state: number;
23
+ /** 连接状态常量枚举:NOT_AUTHENTICATED / AUTHENTICATED / SELECTED / LOGOUT */
24
+ states: {
25
+ NOT_AUTHENTICATED: number;
26
+ AUTHENTICATED: number;
27
+ SELECTED: number;
28
+ LOGOUT: number;
29
+ };
30
+ }
31
+ }