@bobfrankston/rmfmail 1.1.103 → 1.1.105

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.
@@ -1028,26 +1028,63 @@ export class MailxService implements MailxApi {
1028
1028
  const seen = new Set<string>();
1029
1029
  const items: any[] = [];
1030
1030
  let total = 0;
1031
+ // Partial-failure accounting. The old code did `if (r.status !==
1032
+ // "fulfilled") continue` — a folder whose SEARCH rejected
1033
+ // (connection discard, a genuinely slow huge folder hitting the
1034
+ // 90s cap) contributed zero results AND zero signal. The user saw
1035
+ // "No results" for a search that simply never ran in the folder
1036
+ // holding the match. Now we count failures and hand them back so
1037
+ // the UI can say "searched 88/93 — 5 folders failed, retry".
1038
+ let foldersSearched = 0;
1039
+ let foldersFailed = 0;
1040
+ const failedFolders: string[] = [];
1041
+ let droppedHits = 0;
1042
+
1043
+ // Bounded concurrency. The per-account ops queue already
1044
+ // serializes connection use, so this doesn't change IMAP
1045
+ // parallelism — but firing all ~93 folder promises at once
1046
+ // builds a 90+-deep pending-promise queue with 90+ live
1047
+ // setTimeout timers. Batching keeps that bounded and gives a
1048
+ // natural place to add early-abort later.
1049
+ const SERVER_SEARCH_BATCH = 8;
1031
1050
 
1032
1051
  for (const acct of dbAccounts) {
1033
1052
  const folders = this.db.getFolders(acct.id)
1034
1053
  .filter((f: any) => !(f.flags || []).some((x: string) => /noselect/i.test(x)));
1035
- const results = await Promise.allSettled(
1036
- folders.map(f =>
1037
- this.imapManager.searchAndFetchOnServer(acct.id, f.id, f.path, criteria)
1038
- .then(uids => ({ folderId: f.id, uids }))
1039
- )
1040
- );
1041
- for (const r of results) {
1042
- if (r.status !== "fulfilled") continue;
1043
- for (const uid of r.value.uids) {
1044
- const msg = this.db.getMessageByUid(acct.id, uid, r.value.folderId);
1045
- if (!msg) continue;
1046
- const key = msg.messageId || `${acct.id}:${r.value.folderId}:${uid}`;
1047
- if (seen.has(key)) continue;
1048
- seen.add(key);
1049
- items.push(msg);
1050
- total++;
1054
+ for (let i = 0; i < folders.length; i += SERVER_SEARCH_BATCH) {
1055
+ const batch = folders.slice(i, i + SERVER_SEARCH_BATCH);
1056
+ const results = await Promise.allSettled(
1057
+ batch.map(f =>
1058
+ this.imapManager.searchAndFetchOnServer(acct.id, f.id, f.path, criteria)
1059
+ .then(uids => ({ folder: f, uids }))
1060
+ )
1061
+ );
1062
+ for (let j = 0; j < results.length; j++) {
1063
+ const r = results[j];
1064
+ if (r.status !== "fulfilled") {
1065
+ foldersFailed++;
1066
+ failedFolders.push(`${acct.id}/${batch[j].path}`);
1067
+ console.error(` [server-search] ${acct.id}/${batch[j].path}: ${r.reason?.message || r.reason}`);
1068
+ continue;
1069
+ }
1070
+ foldersSearched++;
1071
+ for (const uid of r.value.uids) {
1072
+ const msg = this.db.getMessageByUid(acct.id, uid, r.value.folder.id);
1073
+ if (!msg) {
1074
+ // SEARCH matched on the server but the
1075
+ // fetch-and-store in searchAndFetchOnServer
1076
+ // didn't land the row. Count it so the total
1077
+ // doesn't silently under-report.
1078
+ droppedHits++;
1079
+ console.error(` [server-search] ${acct.id}/${r.value.folder.path} uid ${uid}: SEARCH hit not in local DB after fetch — dropped`);
1080
+ continue;
1081
+ }
1082
+ const key = msg.messageId || `${acct.id}:${r.value.folder.id}:${uid}`;
1083
+ if (seen.has(key)) continue;
1084
+ seen.add(key);
1085
+ items.push(msg);
1086
+ total++;
1087
+ }
1051
1088
  }
1052
1089
  }
1053
1090
  }
@@ -1055,7 +1092,14 @@ export class MailxService implements MailxApi {
1055
1092
  // Newest first, then paginate.
1056
1093
  items.sort((a: any, b: any) => (b.date?.getTime?.() || 0) - (a.date?.getTime?.() || 0));
1057
1094
  const sliced = items.slice((page - 1) * pageSize, page * pageSize);
1058
- return { items: sliced, total, page, pageSize };
1095
+ if (foldersFailed > 0 || droppedHits > 0) {
1096
+ console.log(` [server-search] q="${q}" — ${total} hits across ${foldersSearched} folders; ${foldersFailed} folders failed, ${droppedHits} hits dropped`);
1097
+ }
1098
+ return {
1099
+ items: sliced, total, page, pageSize,
1100
+ partial: foldersFailed > 0 || droppedHits > 0,
1101
+ foldersSearched, foldersFailed, failedFolders, droppedHits,
1102
+ };
1059
1103
  } else if (scope === "current" && accountId && folderId) {
1060
1104
  // Per-folder search — folder scope is the user's explicit
1061
1105
  // intent, including trash/spam if that's where they are.