@bobfrankston/rmfmail 1.2.224 → 1.2.225

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.
package/client/app.ts CHANGED
@@ -1857,8 +1857,8 @@ async function performUndo(): Promise<void> {
1857
1857
  ? [flag] // simplest restore — full set is unknown here
1858
1858
  : [];
1859
1859
  await updateFlags(entry.accountId, entry.uid, flagsAfter, entry.folderId);
1860
- setRowFlagged(entry.accountId, entry.uid, entry.prevFlagged);
1861
- messageState.updateMessageFlags(entry.accountId, entry.uid, flagsAfter);
1860
+ setRowFlagged(entry.accountId, entry.uid, entry.prevFlagged, entry.folderId);
1861
+ messageState.updateMessageFlags(entry.accountId, entry.uid, flagsAfter, entry.folderId);
1862
1862
  }
1863
1863
  if (statusSync) statusSync.textContent = `Undid flag change on ${op.payload.length} message${op.payload.length !== 1 ? "s" : ""}${remaining}`;
1864
1864
  }
@@ -2075,8 +2075,8 @@ async function bulkFlagSelected(): Promise<void> {
2075
2075
  for (const r of rows) {
2076
2076
  if (flaggedOf(r) === targetFlagged) continue;
2077
2077
  setFlagged(r, targetFlagged);
2078
- setRowFlagged(r.accountId, r.uid, targetFlagged);
2079
- messageState.updateMessageFlags(r.accountId, r.uid, r.flags);
2078
+ setRowFlagged(r.accountId, r.uid, targetFlagged, (r as any).folderId);
2079
+ messageState.updateMessageFlags(r.accountId, r.uid, r.flags, (r as any).folderId);
2080
2080
  try { await updateFlags(r.accountId, r.uid, r.flags, (r as any).folderId); }
2081
2081
  catch (e: any) { console.error(`Bulk flag failed for ${r.accountId}/${r.uid}: ${e?.message || e}`); }
2082
2082
  }
@@ -2097,10 +2097,10 @@ document.getElementById("btn-flag")?.addEventListener("click", async () => {
2097
2097
  updateFlagButton();
2098
2098
  try {
2099
2099
  await updateFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId);
2100
- messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);
2100
+ messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId);
2101
2101
  // Row owns its own DOM \u2014 go through the row object so class + star
2102
2102
  // update atomically and the list/preview stay in sync.
2103
- setRowFlagged(sel.accountId, sel.uid, !wasFlagged);
2103
+ setRowFlagged(sel.accountId, sel.uid, !wasFlagged, (sel as any).folderId);
2104
2104
  } catch (e: unknown) {
2105
2105
  // Revert local state on failure so visual + data stay consistent.
2106
2106
  setFlagged(sel, wasFlagged);
@@ -2172,9 +2172,20 @@ async function spamSelectedMessages(): Promise<void> {
2172
2172
  // messages' current folder instead of guessing by uid alone.
2173
2173
  if (result?.targetFolderId) spamUndo.targetFolderId = result.targetFolderId;
2174
2174
  })
2175
- .catch(e => {
2175
+ .catch(async e => {
2176
2176
  console.error(`[spam] ${accountId} failed:`, e);
2177
- if (statusSync) statusSync.textContent = `Spam sync issue (${accountId}): ${e?.message || e}`;
2177
+ // The optimistic remove above already took these rows off the
2178
+ // list. If the move genuinely failed, leaving them gone is a
2179
+ // LIE — the message is still in the inbox and reappears at the
2180
+ // next sync looking like it "came back" (Bob 2026-08-07, where
2181
+ // the Android markAsSpamMessages stub threw on every click and
2182
+ // only a status-bar line said so). Put the list back and say it
2183
+ // loudly enough to be seen on a phone.
2184
+ if (statusSync) statusSync.textContent = `Spam FAILED (${accountId}): ${e?.message || e}`;
2185
+ try { await reloadCurrentFolder(); } catch { /* list rebuilds on next sync */ }
2186
+ window.dispatchEvent(new CustomEvent("mailx-alert", {
2187
+ detail: { message: `Couldn't move ${uids.length} message(s) to spam: ${e?.message || e}`, key: "spam-move" },
2188
+ }));
2178
2189
  });
2179
2190
  }
2180
2191
  }
@@ -2236,7 +2247,7 @@ document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
2236
2247
  const wasSeen = seenOf(sel);
2237
2248
  setSeen(sel, !wasSeen);
2238
2249
  updateFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId).then(() => {
2239
- messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);
2250
+ messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags, (sel as any).folderId);
2240
2251
  const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`) as HTMLElement;
2241
2252
  if (row) row.classList.toggle("unread", wasSeen);
2242
2253
  }).catch(() => { setSeen(sel, wasSeen); });
@@ -5995,7 +6006,7 @@ document.addEventListener("mailx-popout-action", ((e: any) => {
5995
6006
  setFlagged(msg, !wasFlagged);
5996
6007
  updateFlags(accountId, msg.uid, msg.flags, msg.folderId).catch((err: any) =>
5997
6008
  console.error(`[popout] flag failed: ${err?.message || err}`));
5998
- setRowFlagged(accountId, msg.uid, !wasFlagged);
6009
+ setRowFlagged(accountId, msg.uid, !wasFlagged, msg.folderId);
5999
6010
  }
6000
6011
  }) as EventListener);
6001
6012
 
@@ -382,8 +382,40 @@ export async function refreshPriorityIndex() {
382
382
  }
383
383
  catch { /* service may not be ready yet */ }
384
384
  }
385
- function rowKey(accountId, uid) {
386
- return `${accountId}:${uid}`;
385
+ /** Row lookup key. MUST include the folder: an IMAP UID is unique only WITHIN
386
+ * a folder, so in a unified view ("All Inboxes", search results) two entirely
387
+ * different messages routinely share one (account, uid) pair. Keyed on
388
+ * (account, uid) alone, the second row's attach() OVERWROTE the first in
389
+ * rowByKey — after which setRowFlagged / setRowSeen / focusByIdentity for
390
+ * either message resolved to whichever row registered last, and the loser's
391
+ * detach() deleted the winner's entry. That is why an accidental star
392
+ * "refused to go away" and why a second message from the same sender came up
393
+ * starred too (Bob 2026-08-07, All Inboxes). Same invariant the DB has had
394
+ * since the uid-is-not-identity corruption. */
395
+ function rowKey(accountId, uid, folderId) {
396
+ return `${accountId}:${folderId ?? ""}:${uid}`;
397
+ }
398
+ /** Resolve a row when the caller may not know the folder. With a folderId it
399
+ * is an exact hit; without one it scans, and a UID that matches more than one
400
+ * folder returns nothing rather than guessing — silently acting on the wrong
401
+ * message is the failure mode this whole key change exists to stop. */
402
+ function findRow(accountId, uid, folderId) {
403
+ if (folderId !== undefined && folderId !== null) {
404
+ const exact = rowByKey.get(rowKey(accountId, uid, folderId));
405
+ if (exact)
406
+ return exact;
407
+ }
408
+ const hits = [];
409
+ for (const row of rowByKey.values()) {
410
+ if (row.accountId === accountId && row.msg.uid === uid)
411
+ hits.push(row);
412
+ }
413
+ if (hits.length === 1)
414
+ return hits[0];
415
+ if (hits.length > 1) {
416
+ console.warn(`[message-list] ambiguous row lookup ${accountId}/uid=${uid} — ${hits.length} folders match; caller must pass folderId`);
417
+ }
418
+ return undefined;
387
419
  }
388
420
  function focusRow(row, opts = {}) {
389
421
  // Single-select invariant: this row is the ONLY highlight, and it is the
@@ -458,7 +490,7 @@ export function getCurrentFocused() {
458
490
  * keyboard nav, post-delete handoff. Returns true if a row was found
459
491
  * and focused. */
460
492
  function focusByIdentity(accountId, uid, opts = {}) {
461
- const row = rowByKey.get(rowKey(accountId, uid));
493
+ const row = findRow(accountId, uid, opts.folderId);
462
494
  if (!row)
463
495
  return false;
464
496
  focusRow(row, opts);
@@ -468,8 +500,8 @@ function focusByIdentity(accountId, uid, opts = {}) {
468
500
  * the toolbar Flag button so the visible star tracks the data instead of
469
501
  * drifting until the next list rebuild. Calls into the row object's own
470
502
  * setFlaggedClass — atomic class+text update, can't desync. */
471
- export function setRowFlagged(accountId, uid, yes) {
472
- const row = rowByKey.get(rowKey(accountId, uid));
503
+ export function setRowFlagged(accountId, uid, yes, folderId) {
504
+ const row = findRow(accountId, uid, folderId);
473
505
  if (row)
474
506
  row.setFlaggedClass(yes);
475
507
  }
@@ -480,8 +512,8 @@ export function setRowFlagged(accountId, uid, yes) {
480
512
  * a right-click during the 2-s dwell window read the row as "read"
481
513
  * and offered "Mark unread" on a message that wasn't actually marked
482
514
  * yet. */
483
- export function setRowSeen(accountId, uid, yes) {
484
- const row = rowByKey.get(rowKey(accountId, uid));
515
+ export function setRowSeen(accountId, uid, yes, folderId) {
516
+ const row = findRow(accountId, uid, folderId);
485
517
  if (row)
486
518
  row.setUnreadClass(!yes);
487
519
  }
@@ -490,7 +522,7 @@ export function setRowSeen(accountId, uid, yes) {
490
522
  function selectedRows() {
491
523
  const out = [];
492
524
  for (const s of getSelectedMessages()) {
493
- const row = rowByKey.get(rowKey(s.accountId, s.uid));
525
+ const row = findRow(s.accountId, s.uid, s.folderId);
494
526
  if (row)
495
527
  out.push(row);
496
528
  }
@@ -503,7 +535,7 @@ function visibleRowObjs() {
503
535
  if (!body)
504
536
  return [];
505
537
  return Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
506
- .map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid))))
538
+ .map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid), el.dataset.folderId)))
507
539
  .filter((r) => !!r);
508
540
  }
509
541
  /** The set a bulk action targets: the SELECTED rows if any are marked,
@@ -695,6 +727,15 @@ export function releaseFocus() {
695
727
  const body = document.getElementById("ml-body");
696
728
  body?.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
697
729
  viewerClear();
730
+ // On a narrow screen the viewer is the ONLY visible pane (the list carries
731
+ // narrow-hidden), so clearing it leaves a white screen reading "Select a
732
+ // message to read" with no list to select from — and in viewer-fullscreen
733
+ // not even a back button. That is a dead end: the user had to fold/unfold
734
+ // the phone to escape (Bob 2026-08-07, after sending spam from the viewer).
735
+ // No message ⟹ nothing to view ⟹ go back to the list.
736
+ document.body.classList.remove("viewer-fullscreen");
737
+ document.getElementById("message-viewer")?.classList.remove("narrow-active");
738
+ document.getElementById("message-list")?.classList.remove("narrow-hidden");
698
739
  document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: null }));
699
740
  }
700
741
  /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
@@ -1071,7 +1112,7 @@ export function removeMessagesAndReconcile(uids) {
1071
1112
  // scheme, but only detach via the row object if it actually
1072
1113
  // owns THIS element (a cross-folder uid collision could point
1073
1114
  // it at a different row). Otherwise remove the DOM node directly.
1074
- const dead = rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid)));
1115
+ const dead = rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid), el.dataset.folderId));
1075
1116
  if (dead && dead.el === el)
1076
1117
  dead.detach();
1077
1118
  else
@@ -1084,7 +1125,7 @@ export function removeMessagesAndReconcile(uids) {
1084
1125
  }
1085
1126
  if (outcome.focusedWasRemoved) {
1086
1127
  const survivor = outcome.nextSurvivor;
1087
- if (survivor && focusByIdentity(survivor.accountId, survivor.uid)) {
1128
+ if (survivor && focusByIdentity(survivor.accountId, survivor.uid, { folderId: survivor.folderId })) {
1088
1129
  // focusByIdentity handled the transition (DOM class + viewer).
1089
1130
  return;
1090
1131
  }
@@ -1096,7 +1137,7 @@ export function removeMessagesAndReconcile(uids) {
1096
1137
  // order; the first row that has a live DOM/rowByKey entry wins.
1097
1138
  const remaining = state.getMessages();
1098
1139
  for (const m of remaining) {
1099
- if (focusByIdentity(m.accountId, m.uid))
1140
+ if (focusByIdentity(m.accountId, m.uid, { folderId: m.folderId }))
1100
1141
  return;
1101
1142
  }
1102
1143
  // Truly empty folder (state.length === 0). Now releasing focus is
@@ -2011,7 +2052,7 @@ class MessageRow {
2011
2052
  /** Insert this row into a list body and register it in the lookup map. */
2012
2053
  attach(body) {
2013
2054
  body.appendChild(this.el);
2014
- rowByKey.set(rowKey(this.accountId, this.msg.uid), this);
2055
+ rowByKey.set(rowKey(this.accountId, this.msg.uid, this.msg.folderId), this);
2015
2056
  }
2016
2057
  /** Remove this row from the DOM and the lookup map. If it was the
2017
2058
  * focused row, the controller is responsible for releasing focus
@@ -2019,7 +2060,7 @@ class MessageRow {
2019
2060
  * may want to hand focus to a sibling instead). */
2020
2061
  detach() {
2021
2062
  this.el.remove();
2022
- rowByKey.delete(rowKey(this.accountId, this.msg.uid));
2063
+ rowByKey.delete(rowKey(this.accountId, this.msg.uid, this.msg.folderId));
2023
2064
  }
2024
2065
  setSelected(yes) { this.el.classList.toggle("selected", yes); }
2025
2066
  get isSelected() { return this.el.classList.contains("selected"); }
@@ -2560,7 +2601,7 @@ onEvent((ev) => {
2560
2601
  return;
2561
2602
  if (!ev.accountId || ev.previousDraftUid == null)
2562
2603
  return;
2563
- const row = rowByKey.get(rowKey(ev.accountId, ev.previousDraftUid));
2604
+ const row = findRow(ev.accountId, Number(ev.previousDraftUid), ev.folderId);
2564
2605
  if (row)
2565
2606
  row.setDate(typeof ev.savedAt === "number" ? ev.savedAt : Date.now());
2566
2607
  });