@bobfrankston/rmfmail 1.2.247 → 1.2.250

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 (53) hide show
  1. package/client/app.bundle.js +239 -50
  2. package/client/app.bundle.js.map +4 -4
  3. package/client/app.js +23 -36
  4. package/client/app.js.map +1 -1
  5. package/client/app.ts +17 -36
  6. package/client/components/folder-picker.js +3 -2
  7. package/client/components/folder-picker.js.map +1 -1
  8. package/client/components/folder-picker.ts +3 -2
  9. package/client/components/folder-tree.js +90 -12
  10. package/client/components/folder-tree.js.map +1 -1
  11. package/client/components/folder-tree.ts +89 -12
  12. package/client/components/message-list.js +35 -2
  13. package/client/components/message-list.js.map +1 -1
  14. package/client/components/message-list.ts +32 -2
  15. package/client/components/message-viewer.js +149 -11
  16. package/client/components/message-viewer.js.map +1 -1
  17. package/client/components/message-viewer.ts +139 -11
  18. package/client/compose/compose.bundle.js +11 -0
  19. package/client/compose/compose.bundle.js.map +2 -2
  20. package/client/help/search-help.js +6 -1
  21. package/client/help/search-help.js.map +1 -1
  22. package/client/help/search-help.ts +6 -1
  23. package/client/lib/api-client.js +14 -0
  24. package/client/lib/api-client.js.map +1 -1
  25. package/client/lib/api-client.ts +9 -0
  26. package/client/lib/fold-text.js +18 -0
  27. package/client/lib/fold-text.js.map +1 -0
  28. package/client/lib/fold-text.ts +17 -0
  29. package/client/styles/components.css +23 -3
  30. package/package.json +1 -1
  31. package/packages/mailx-imap/package-lock.json +2 -2
  32. package/packages/mailx-imap/package.json +1 -1
  33. package/packages/mailx-service/index.d.ts +16 -0
  34. package/packages/mailx-service/index.d.ts.map +1 -1
  35. package/packages/mailx-service/index.js +49 -1
  36. package/packages/mailx-service/index.js.map +1 -1
  37. package/packages/mailx-service/index.ts +48 -1
  38. package/packages/mailx-service/jsonrpc.js +2 -0
  39. package/packages/mailx-service/jsonrpc.js.map +1 -1
  40. package/packages/mailx-service/jsonrpc.ts +2 -0
  41. package/packages/mailx-service/package.json +1 -1
  42. package/packages/mailx-settings/package.json +1 -1
  43. package/packages/mailx-store/db.d.ts.map +1 -1
  44. package/packages/mailx-store/db.js +5 -2
  45. package/packages/mailx-store/db.js.map +1 -1
  46. package/packages/mailx-store/db.ts +5 -2
  47. package/packages/mailx-store/package.json +1 -1
  48. package/packages/mailx-store-web/package.json +1 -1
  49. package/packages/mailx-types/mailx-api.d.ts +7 -0
  50. package/packages/mailx-types/mailx-api.d.ts.map +1 -1
  51. package/packages/mailx-types/mailx-api.ts +4 -0
  52. package/packages/mailx-types/package.json +1 -1
  53. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-16772 → node_modules.npmglobalize-stash-52956}/.package-lock.json +0 -0
@@ -6,6 +6,7 @@
6
6
  import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, moveFolderToTrash, emptyFolder, setupAccount, getDeviceAccounts, getVersion, syncAccount, popoutMainWindow } from "../lib/api-client.js";
7
7
  import { showContextMenu, type MenuItem } from "./context-menu.js";
8
8
  import { openTab } from "./tabs.js";
9
+ import { foldText } from "../lib/fold-text.js";
9
10
 
10
11
  type FolderSelectHandler = (accountId: string, folderId: number, folderName: string, specialUse: string) => void;
11
12
  // Unified inbox uses folderId = -1 as a sentinel
@@ -47,6 +48,43 @@ function collapseDragExpanded(): boolean {
47
48
  return true;
48
49
  }
49
50
 
51
+ // ── Sidebar "Find folder" filter ──
52
+ // Matching runs against the folder DATA, not the rendered rows. Folders under
53
+ // a collapsed parent are never in the DOM at all (renderNode only recurses
54
+ // when expanded) and a collapsed ACCOUNT isn't even fetched, so the old
55
+ // DOM-hiding filter could only ever find the handful of folders already on
56
+ // screen — with ~90 mostly-collapsed folders that reads as "search doesn't
57
+ // find my folder" (Bob 2026-08-11). Now every account's full folder list is
58
+ // searched, name and path, case- and accent-insensitively, and the ancestors
59
+ // of each hit are force-expanded so the match is actually visible.
60
+ let folderFilter = "";
61
+
62
+ /** Set (or clear, with "") the sidebar folder filter and re-render the tree. */
63
+ export function setFolderFilter(query: string): void {
64
+ const next = foldText(query.trim());
65
+ if (next === folderFilter) return;
66
+ folderFilter = next;
67
+ const container = document.getElementById("folder-tree");
68
+ if (container) loadFolderTree(container);
69
+ }
70
+
71
+ /** Keep only branches containing a match, folding case and accents on both
72
+ * sides. A folder that matches keeps its whole subtree (so you can see what
73
+ * lives under the hit); a non-matching ancestor keeps only the branches that
74
+ * lead to one, and is recorded in `forceExpand` so it renders open whatever
75
+ * the user's saved collapse state says. */
76
+ function pruneToMatches(nodes: FolderNode[], q: string, forceExpand: Set<string>): FolderNode[] {
77
+ const kept: FolderNode[] = [];
78
+ for (const n of nodes) {
79
+ const selfMatch = foldText(n.name).includes(q) || foldText(n.path).includes(q);
80
+ const keptChildren = pruneToMatches(n.children, q, forceExpand);
81
+ if (!selfMatch && keptChildren.length === 0) continue;
82
+ kept.push({ ...n, children: selfMatch ? n.children : keptChildren });
83
+ if (keptChildren.length > 0) forceExpand.add(`${n.accountId}:${n.path}`);
84
+ }
85
+ return kept;
86
+ }
87
+
50
88
  // Persist expand/collapse state in localStorage
51
89
  const expandState: Record<string, boolean> = JSON.parse(localStorage.getItem("mailx-folders-expanded") || "{}");
52
90
 
@@ -218,11 +256,16 @@ function sortFolders(nodes: FolderNode[]): void {
218
256
  }
219
257
  }
220
258
 
221
- /** Render a folder node and its children recursively */
222
- function renderNode(node: FolderNode, container: HTMLElement, depth: number): void {
259
+ /** Render a folder node and its children recursively.
260
+ * `forceExpand` holds the keys of folders that sit on the path to a filter
261
+ * match — those default to open (the user can still collapse one explicitly,
262
+ * which is why this reads `!== false` rather than forcing `true`). */
263
+ function renderNode(node: FolderNode, container: HTMLElement, depth: number, forceExpand?: Set<string>): void {
223
264
  const hasChildren = node.children.length > 0;
224
265
  const expandKey = `${node.accountId}:${node.path}`;
225
- const isExpanded = expandState[expandKey] === true; // default collapsed
266
+ const isExpanded = forceExpand?.has(expandKey)
267
+ ? expandState[expandKey] !== false
268
+ : expandState[expandKey] === true; // default collapsed
226
269
 
227
270
  const folderEl = document.createElement("div");
228
271
  folderEl.className = "ft-folder";
@@ -598,7 +641,7 @@ function renderNode(node: FolderNode, container: HTMLElement, depth: number): vo
598
641
  // Render children if expanded
599
642
  if (hasChildren && isExpanded) {
600
643
  for (const child of node.children) {
601
- renderNode(child, container, depth + 1);
644
+ renderNode(child, container, depth + 1, forceExpand);
602
645
  }
603
646
  }
604
647
  }
@@ -992,11 +1035,14 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
992
1035
  }
993
1036
 
994
1037
  // Fetch ALL account folder data in parallel BEFORE touching the DOM
1038
+ // A collapsed account normally isn't fetched at all. While a filter is
1039
+ // active we fetch every account — a folder you can't see is exactly
1040
+ // the one you're searching for.
995
1041
  const accountFolderData: { account: any; folders: any[] }[] = await Promise.all(
996
1042
  accounts.map(async (account: any) => {
997
1043
  const accountKey = `account:${account.id}`;
998
1044
  const accountExpanded = expandState[accountKey] !== false;
999
- const folders = accountExpanded ? await getFolders(account.id) : [];
1045
+ const folders = (accountExpanded || folderFilter) ? await getFolders(account.id) : [];
1000
1046
  return { account, folders };
1001
1047
  })
1002
1048
  );
@@ -1010,7 +1056,7 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
1010
1056
  // Unified Inbox — always shown so startup auto-selects it consistently
1011
1057
  // (with one account it's effectively that account's INBOX, but the UI
1012
1058
  // stays uniform so the auto-select path doesn't fork on account count)
1013
- if (accounts.length >= 1) {
1059
+ if (accounts.length >= 1 && (!folderFilter || foldText("All Inboxes").includes(folderFilter))) {
1014
1060
  const unifiedEl = document.createElement("div");
1015
1061
  unifiedEl.className = "ft-folder ft-unified";
1016
1062
  unifiedEl.title = accounts.length > 1
@@ -1032,7 +1078,7 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
1032
1078
  // queue, only shown when something is actually queued. Clicking
1033
1079
  // opens the outbox-view modal (pink rows, cancellable). Lives at
1034
1080
  // the top of the tree so a stuck send is impossible to miss.
1035
- if (lastOutboxTotal > 0) {
1081
+ if (lastOutboxTotal > 0 && (!folderFilter || foldText("Send-pending").includes(folderFilter))) {
1036
1082
  const pendingEl = document.createElement("div");
1037
1083
  pendingEl.className = "ft-folder ft-unified ft-send-pending";
1038
1084
  pendingEl.id = "ft-send-pending";
@@ -1064,7 +1110,9 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
1064
1110
  accountEl.className = "ft-account";
1065
1111
 
1066
1112
  const accountKey = `account:${account.id}`;
1067
- const accountExpanded = expandState[accountKey] !== false; // accounts default expanded
1113
+ // A filter opens every account: its matches have to be reachable
1114
+ // even when the user left the account collapsed.
1115
+ const accountExpanded = folderFilter ? true : expandState[accountKey] !== false; // accounts default expanded
1068
1116
 
1069
1117
  const header = document.createElement("div");
1070
1118
  header.className = "ft-account-header";
@@ -1073,7 +1121,10 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
1073
1121
  const disambiguator = isDup ? ` (${(account as any).email || account.id})` : "";
1074
1122
  header.textContent = `${accountExpanded ? "▾" : "▸"} ${baseLabel}${disambiguator}`;
1075
1123
  header.addEventListener("click", () => {
1076
- expandState[accountKey] = !accountExpanded;
1124
+ // Toggle the SAVED state, not the rendered one — while a
1125
+ // filter forces the account open, a click must still flip
1126
+ // what the user gets back when the filter clears.
1127
+ expandState[accountKey] = expandState[accountKey] === false;
1077
1128
  saveExpandState();
1078
1129
  const treeContainer = document.getElementById("folder-tree");
1079
1130
  if (treeContainer) loadFolderTree(treeContainer);
@@ -1119,11 +1170,25 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
1119
1170
 
1120
1171
  accountEl.appendChild(header);
1121
1172
 
1173
+ // With a filter active an account that has no hit is dropped
1174
+ // entirely (header included) rather than left as a bare label.
1175
+ let filteredOut = false;
1176
+
1122
1177
  if (accountExpanded && folders.length > 0) {
1123
1178
  const delimiter = folders[0]?.delimiter || ".";
1124
- const tree = buildTree(folders, delimiter, account.id);
1179
+ let tree = buildTree(folders, delimiter, account.id);
1125
1180
  sortFolders(tree);
1126
1181
 
1182
+ // Filter: prune to matching branches. An account whose own
1183
+ // label matches keeps all of its folders — "show me everything
1184
+ // under gmail" is the natural reading of typing the account
1185
+ // name into the folder box.
1186
+ const forceExpand = new Set<string>();
1187
+ if (folderFilter && !foldText(`${baseLabel} ${(account as any).email || ""} ${account.id}`).includes(folderFilter)) {
1188
+ tree = pruneToMatches(tree, folderFilter, forceExpand);
1189
+ if (tree.length === 0) filteredOut = true;
1190
+ }
1191
+
1127
1192
  // Case-duplicate detection: fold folder paths to lowercase and
1128
1193
  // flag any whose form matches another. Common with servers that
1129
1194
  // let users create `Archive` and `archive` as distinct folders,
@@ -1139,7 +1204,7 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
1139
1204
  for (const [k, c] of lowerCounts) if (c > 1) duplicatePaths.add(k);
1140
1205
 
1141
1206
  for (const node of tree) {
1142
- renderNode(node, accountEl, 1);
1207
+ renderNode(node, accountEl, 1, forceExpand);
1143
1208
  }
1144
1209
 
1145
1210
  if (duplicatePaths.size > 0) {
@@ -1152,9 +1217,21 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
1152
1217
  }
1153
1218
  });
1154
1219
  }
1220
+ } else if (folderFilter) {
1221
+ filteredOut = true;
1155
1222
  }
1156
1223
 
1157
- fragment.appendChild(accountEl);
1224
+ if (!filteredOut) fragment.appendChild(accountEl);
1225
+ }
1226
+
1227
+ // Nothing matched anywhere — say so instead of showing a blank rail,
1228
+ // which reads as "the tree broke" (name the query, per the rule that
1229
+ // status text names its target).
1230
+ if (folderFilter && !fragment.querySelector(".ft-folder")) {
1231
+ const empty = document.createElement("div");
1232
+ empty.className = "ft-filter-empty";
1233
+ empty.textContent = `No folder matches "${(document.getElementById("ft-filter-input") as HTMLInputElement | null)?.value || folderFilter}"`;
1234
+ fragment.appendChild(empty);
1158
1235
  }
1159
1236
 
1160
1237
  // Atomic swap — single reflow, no intermediate empty state
@@ -7,6 +7,7 @@ import * as state from "../lib/message-state.js";
7
7
  import { showMessage as viewerShow, clearViewer as viewerClear } from "./message-viewer.js";
8
8
  import { showContextMenu } from "./context-menu.js";
9
9
  import { pickFolder } from "./folder-picker.js";
10
+ import { foldText } from "../lib/fold-text.js";
10
11
  import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
11
12
  let onMessageSelect;
12
13
  let currentAccountId;
@@ -37,7 +38,7 @@ let liveFilterText = "";
37
38
  * it (background reloads resume). Lower-cased once here so the per-row test
38
39
  * is a plain substring check. */
39
40
  export function setLiveFilter(query) {
40
- liveFilterText = (query || "").trim().toLowerCase();
41
+ liveFilterText = foldText((query || "").trim());
41
42
  const body = document.getElementById("ml-body");
42
43
  if (body)
43
44
  applyLiveFilter(body);
@@ -51,7 +52,7 @@ function applyLiveFilter(body) {
51
52
  return;
52
53
  }
53
54
  for (const row of body.querySelectorAll(".ml-row")) {
54
- const text = row.textContent?.toLowerCase() || "";
55
+ const text = foldText(row.textContent);
55
56
  row.classList.toggle("filter-hidden", !text.includes(liveFilterText));
56
57
  }
57
58
  }
@@ -1982,11 +1983,22 @@ class MessageRow {
1982
1983
  const icons = document.createElement("span");
1983
1984
  icons.className = "ml-status-icons";
1984
1985
  renderStatusIcons(icons, msg);
1986
+ // Size, immediately left of the date (Bob 2026-08-10). It lives INSIDE
1987
+ // the date cell rather than as a sixth grid column so it lands in the
1988
+ // same place in all three list layouts — default, two-line and
1989
+ // eleanor-view each declare their own column set, and a new column
1990
+ // would have to be threaded through every one of them.
1991
+ const size = document.createElement("span");
1992
+ size.className = "ml-size";
1993
+ size.textContent = formatSize(msg.size);
1994
+ if (msg.size > 0)
1995
+ size.title = `${msg.size.toLocaleString()} bytes`;
1985
1996
  const dateText = document.createElement("span");
1986
1997
  dateText.className = "ml-date-text";
1987
1998
  dateText.textContent = formatDate(effectiveDate(msg));
1988
1999
  dateText.title = dateBasis === "received" ? "Received date" : "Sent date";
1989
2000
  date.appendChild(icons);
2001
+ date.appendChild(size);
1990
2002
  date.appendChild(dateText);
1991
2003
  row.appendChild(avatar);
1992
2004
  row.appendChild(flag);
@@ -2525,6 +2537,27 @@ function appendMessages(body, accountId, items) {
2525
2537
  row.attach(body);
2526
2538
  }
2527
2539
  }
2540
+ /**
2541
+ * Message size for the list, in the width of a few characters.
2542
+ *
2543
+ * Deliberately coarse: the value is there to answer "is this a note or a
2544
+ * 9 MB bounce", not to be an accounting figure — the exact byte count is on
2545
+ * the tooltip. Sizes round to K below a megabyte and to one decimal above,
2546
+ * so the column never grows past five characters and nothing shifts as you
2547
+ * scroll. Anything under 1 KB reads "<1K" rather than "0K", which would look
2548
+ * like a broken message rather than a short one.
2549
+ */
2550
+ function formatSize(bytes) {
2551
+ if (!bytes || bytes <= 0)
2552
+ return "";
2553
+ if (bytes < 1024)
2554
+ return "<1K";
2555
+ const kb = bytes / 1024;
2556
+ if (kb < 1024)
2557
+ return `${Math.round(kb)}K`;
2558
+ const mb = kb / 1024;
2559
+ return mb < 10 ? `${mb.toFixed(1)}M` : `${Math.round(mb)}M`;
2560
+ }
2528
2561
  function formatDate(epochMs) {
2529
2562
  const d = new Date(epochMs);
2530
2563
  const now = new Date();