@bobfrankston/mailx 1.0.393 → 1.0.394

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.
@@ -24,6 +24,16 @@ let touchWasScroll = false;
24
24
  // (text columns default asc, date defaults desc).
25
25
  let currentSort = "date";
26
26
  let currentSortDir = "desc";
27
+ /** Atomic focus: update shared state + notify viewer in one call.
28
+ * First slice of S56 (row-objects-own-preview) — consolidates the two
29
+ * parallel selection paths (state.select + onMessageSelect) so the eventual
30
+ * Row-class migration touches exactly one call site. The viewer's `gen`
31
+ * token still cancels stale fetches; this just makes the transition
32
+ * indivisible at the caller level. */
33
+ function focusMessage(accountId, msg) {
34
+ state.select(msg);
35
+ onMessageSelect(accountId, msg.uid, msg.folderId);
36
+ }
27
37
  /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
28
38
  * Called from the bodyCached service event — covers both background prefetch
29
39
  * and on-demand fetch. No-op for rows not currently rendered. */
@@ -473,8 +483,7 @@ export async function showThreadPopup(pillEl, headMsg) {
473
483
  item.appendChild(date);
474
484
  item.appendChild(subject);
475
485
  item.addEventListener("click", async () => {
476
- state.select({ accountId: msg.accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject, from: msg.from, to: msg.to, cc: msg.cc, date: msg.date, flags: msg.flags, size: msg.size, preview: msg.preview, hasAttachments: msg.hasAttachments });
477
- onMessageSelect(msg.accountId, msg.uid, msg.folderId);
486
+ focusMessage(msg.accountId, { accountId: msg.accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject, from: msg.from, to: msg.to, cc: msg.cc, date: msg.date, flags: msg.flags, size: msg.size, preview: msg.preview, hasAttachments: msg.hasAttachments });
478
487
  popup.remove();
479
488
  });
480
489
  popup.appendChild(item);
@@ -640,9 +649,7 @@ function appendMessages(body, accountId, items) {
640
649
  }
641
650
  lastClickedRow = row;
642
651
  row.classList.remove("unread");
643
- // Update shared state + notify viewer
644
- state.select(msg);
645
- onMessageSelect(msgAccountId, msg.uid, msg.folderId);
652
+ focusMessage(msgAccountId, msg);
646
653
  });
647
654
  // Q64: double-click → pop out the message in a floating overlay so
648
655
  // the user can read it without losing the selected list context.
@@ -718,8 +725,7 @@ function appendMessages(body, accountId, items) {
718
725
  clearSelection();
719
726
  row.classList.add("selected");
720
727
  lastClickedRow = row;
721
- state.select(msg);
722
- onMessageSelect(msgAccountId, msg.uid, msg.folderId);
728
+ focusMessage(msgAccountId, msg);
723
729
  }
724
730
  const isSeen = msg.flags.includes("\\Seen");
725
731
  const isFlagged = msg.flags.includes("\\Flagged");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.393",
3
+ "version": "1.0.394",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -365,9 +365,13 @@ class AndroidSyncManager {
365
365
  }
366
366
  }
367
367
  async fetchMessageBody(accountId, folderId, uid) {
368
+ const t0 = Date.now();
368
369
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
369
- return this.bodyStore.getMessage(accountId, folderId, uid);
370
+ const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
371
+ console.log(`[fetchBody] cache hit ${accountId}/${folderId}/${uid} (${Date.now() - t0}ms)`);
372
+ return cached;
370
373
  }
374
+ console.log(`[fetchBody] cache miss ${accountId}/${folderId}/${uid} — fetching`);
371
375
  const provider = this.getProvider(accountId);
372
376
  if (!provider) {
373
377
  console.warn(`[fetchBody] No provider for ${accountId}`);
@@ -376,27 +380,41 @@ class AndroidSyncManager {
376
380
  // Look up the Gmail providerId stored in body_path during sync
377
381
  const envelope = this.db.getMessageByUid(accountId, uid, folderId);
378
382
  const bp = envelope?.bodyPath || "";
379
- let msg = null;
380
- if (bp.startsWith("gmail:") && provider.fetchById) {
381
- const providerId = bp.substring(6);
382
- msg = await provider.fetchById(providerId, { source: true });
383
- }
384
- else {
385
- // Fallback: list-and-find by UID (slow, fragile)
383
+ // 60 s wall-clock cap — infinite hang was the user-reported symptom
384
+ // ("fetch message body on android is infinite"). A dead BridgeTransport
385
+ // socket won't recover by waiting. Legit fetches finish in seconds.
386
+ const FETCH_TIMEOUT_MS = 60_000;
387
+ const fetchPromise = (async () => {
388
+ if (bp.startsWith("gmail:") && provider.fetchById) {
389
+ const providerId = bp.substring(6);
390
+ return provider.fetchById(providerId, { source: true });
391
+ }
386
392
  const folders = this.db.getFolders(accountId);
387
393
  const folder = folders.find(f => f.id === folderId);
388
394
  if (!folder)
389
395
  return null;
390
- msg = await provider.fetchOne(folder.path, uid, { source: true });
396
+ return provider.fetchOne(folder.path, uid, { source: true });
397
+ })();
398
+ let msg = null;
399
+ try {
400
+ msg = await Promise.race([
401
+ fetchPromise,
402
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)), FETCH_TIMEOUT_MS)),
403
+ ]);
404
+ }
405
+ catch (e) {
406
+ console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
407
+ throw e;
391
408
  }
392
409
  if (!msg?.source) {
393
- console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp})`);
410
+ console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`);
394
411
  return null;
395
412
  }
396
413
  // Encode the UTF-8 string back to bytes for storage
397
414
  const raw = new TextEncoder().encode(msg.source);
398
415
  await this.bodyStore.putMessage(accountId, folderId, uid, raw);
399
416
  this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
417
+ console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
400
418
  return raw;
401
419
  }
402
420
  async updateFlagsLocal(accountId, uid, folderId, flags) {
@@ -241,33 +241,62 @@ export class SyncManager {
241
241
  }
242
242
  }
243
243
  async fetchMessageBody(accountId, folderId, uid) {
244
+ const t0 = Date.now();
245
+ // Cache first — IndexedDB lookup is O(1) and should always win on a
246
+ // previously-fetched body. If this path misses on something the user
247
+ // clearly fetched before, the cache is broken (wrong key shape, wiped
248
+ // IndexedDB, account-id change) and needs investigation — log so it's
249
+ // visible.
244
250
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
245
- return this.bodyStore.getMessage(accountId, folderId, uid);
251
+ const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
252
+ console.log(`[fetchBody] cache hit ${accountId}/${folderId}/${uid} (${Date.now() - t0}ms)`);
253
+ return cached;
246
254
  }
255
+ console.log(`[fetchBody] cache miss ${accountId}/${folderId}/${uid} — fetching from provider`);
247
256
  const provider = this.getProvider(accountId);
248
- if (!provider)
257
+ if (!provider) {
258
+ console.warn(`[fetchBody] no provider for ${accountId}`);
249
259
  return null;
260
+ }
250
261
  const envelope = this.db.getMessageByUid(accountId, uid, folderId);
251
262
  const bp = envelope?.bodyPath || "";
252
- let msg = null;
253
- if (bp.startsWith("gmail:") && provider.fetchById) {
254
- const providerId = bp.substring(6);
255
- msg = await provider.fetchById(providerId, { source: true });
256
- }
257
- else {
263
+ // Wall-clock timeout — without this, an IMAP provider that dangles
264
+ // (Dovecot silently dropped the socket, BridgeTransport stalled) hung
265
+ // the viewer forever. 60 s is generous for a single-message BODY[]
266
+ // fetch on a phone connection; legit large bodies finish in seconds,
267
+ // and anything longer means the socket is dead and retrying on a fresh
268
+ // one is faster than waiting.
269
+ const FETCH_TIMEOUT_MS = 60_000;
270
+ const fetchPromise = (async () => {
271
+ if (bp.startsWith("gmail:") && provider.fetchById) {
272
+ const providerId = bp.substring(6);
273
+ return provider.fetchById(providerId, { source: true });
274
+ }
258
275
  const folders = this.db.getFolders(accountId);
259
276
  const folder = folders.find((f) => f.id === folderId);
260
277
  if (!folder)
261
278
  return null;
262
- msg = await provider.fetchOne(folder.path, uid, { source: true });
279
+ return provider.fetchOne(folder.path, uid, { source: true });
280
+ })();
281
+ let msg = null;
282
+ try {
283
+ msg = await Promise.race([
284
+ fetchPromise,
285
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)), FETCH_TIMEOUT_MS)),
286
+ ]);
287
+ }
288
+ catch (e) {
289
+ console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
290
+ throw e;
263
291
  }
264
292
  if (!msg?.source) {
265
- console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp})`);
293
+ console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`);
266
294
  return null;
267
295
  }
268
296
  const raw = new TextEncoder().encode(msg.source);
269
297
  await this.bodyStore.putMessage(accountId, folderId, uid, raw);
270
298
  this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
299
+ console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
271
300
  return raw;
272
301
  }
273
302
  async updateFlagsLocal(accountId, uid, folderId, flags) {
package/rebuild.cmd ADDED
@@ -0,0 +1,23 @@
1
+ @echo off
2
+ REM ─────────────────────────────────────────────────────────────────────────
3
+ REM rebuild.cmd — one-shot release: npmglobalize + APK.
4
+ REM Runs the desktop release workflow (npmglobalize commits, tags, bumps,
5
+ REM publishes the npm package) and, only if that succeeds, rebuilds the
6
+ REM Android MAUI APK + copies it into download/apks/ + updates versions.json.
7
+ REM Anything non-zero from npmglobalize aborts before the APK stage — no
8
+ REM point building an APK against a failed publish.
9
+ REM ─────────────────────────────────────────────────────────────────────────
10
+
11
+ cls
12
+ setlocal
13
+ cd /d %~dp0
14
+
15
+ call npmglobalize
16
+ if errorlevel 1 (
17
+ echo [rebuild] npmglobalize failed with errorlevel %ERRORLEVEL% — skipping build-apk
18
+ endlocal
19
+ exit /b %ERRORLEVEL%
20
+ )
21
+
22
+ call "%~dp0build-apk.cmd"
23
+ endlocal
package/unbash.cmd ADDED
@@ -0,0 +1,55 @@
1
+ @echo off
2
+ REM ─────────────────────────────────────────────────────────────────────────
3
+ REM unbash.cmd — surgically remove a stuck session-env subdir so Claude
4
+ REM Code's Bash tool can recreate it. One subdir per concurrent Claude
5
+ REM session; only nuke the stuck one, not all of them.
6
+ REM
7
+ REM Usage:
8
+ REM unbash.cmd List existing session-env subdirs.
9
+ REM unbash.cmd <uuid> Remove that specific subdir.
10
+ REM unbash.cmd stale Remove subdirs with no writes in >60 min
11
+ REM (abandoned from crashed sessions).
12
+ REM unbash.cmd all Remove every subdir (nuclear — kills
13
+ REM state of live concurrent sessions too).
14
+ REM ─────────────────────────────────────────────────────────────────────────
15
+
16
+ setlocal ENABLEEXTENSIONS
17
+ set BASE=%USERPROFILE%\.claude\session-env
18
+
19
+ if not exist "%BASE%" (
20
+ echo [unbash] No session-env directory at %BASE% — nothing to do.
21
+ endlocal
22
+ exit /b 0
23
+ )
24
+
25
+ if "%~1"=="" (
26
+ echo [unbash] Subdirs under %BASE%:
27
+ dir /b /ad "%BASE%" 2>nul
28
+ echo.
29
+ echo [unbash] Pass a UUID to remove just one, 'stale' to remove old ones,
30
+ echo or 'all' to nuke everything ^(kills live sessions too^).
31
+ endlocal
32
+ exit /b 0
33
+ )
34
+
35
+ if /I "%~1"=="all" (
36
+ rmdir /s /q "%BASE%"
37
+ echo [unbash] Removed all session-env subdirs.
38
+ endlocal
39
+ exit /b 0
40
+ )
41
+
42
+ if /I "%~1"=="stale" (
43
+ powershell -NoProfile -Command "Get-ChildItem -Directory '%BASE%' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-60) } | ForEach-Object { Write-Host ('[unbash] removing stale ' + $_.Name); Remove-Item -Recurse -Force $_.FullName }"
44
+ endlocal
45
+ exit /b 0
46
+ )
47
+
48
+ if not exist "%BASE%\%~1" (
49
+ echo [unbash] No subdir %~1 under %BASE%.
50
+ endlocal
51
+ exit /b 1
52
+ )
53
+ rmdir /s /q "%BASE%\%~1"
54
+ echo [unbash] Removed %BASE%\%~1
55
+ endlocal