@bobfrankston/mailx 1.0.235 → 1.0.237

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.
@@ -458,7 +458,14 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
458
458
  catch (e) {
459
459
  const err = e.message || "Unknown error";
460
460
  console.error("showMessage error:", e);
461
- // Don't retry on "not found" or known errors only on connection issues
461
+ // "Message was deleted from the server" — the service already dropped
462
+ // the local row. Remove it from the list so the UI advances to the next
463
+ // message instead of sitting on a stale error banner.
464
+ const isDeleted = /deleted from the server|isNotFound/.test(err);
465
+ if (isDeleted) {
466
+ state.removeMessages([{ accountId, uid }]);
467
+ return;
468
+ }
462
469
  const isNotFound = err.includes("not found") || err.includes("Not Found") || err.includes("404");
463
470
  if (!isNotFound && retryCount < 3) {
464
471
  retryCount++;
@@ -532,6 +539,15 @@ document.addEventListener("mouseover", e => {
532
539
  const a = e.target.closest("a[href]");
533
540
  window.parent.postMessage({ type: "linkHover", url: a ? a.href : "" }, "*");
534
541
  });
542
+ // Intercept link clicks — Android WebView silently drops window.open, so forward to parent
543
+ document.addEventListener("click", e => {
544
+ const a = e.target.closest("a[href]");
545
+ if (!a) return;
546
+ const url = a.href;
547
+ if (!url || url.startsWith("javascript:") || url.startsWith("#")) return;
548
+ e.preventDefault();
549
+ window.parent.postMessage({ type: "linkClick", url: url }, "*");
550
+ }, true);
535
551
  </script>
536
552
  </head><body>${html}</body></html>`;
537
553
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.235",
3
+ "version": "1.0.237",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.297",
27
+ "@bobfrankston/msger": "^0.1.299",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -78,7 +78,7 @@
78
78
  "@bobfrankston/iflow-node": "^0.1.2",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.297",
81
+ "@bobfrankston/msger": "^0.1.299",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -132,9 +132,16 @@ export declare class ImapManager extends EventEmitter {
132
132
  /** Fetch a single message body on demand, caching in the store.
133
133
  * Uses its own fresh connection — never blocked by background prefetch. */
134
134
  fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
135
- /** Fetch message body via Gmail/Outlook API */
135
+ /** Fetch message body via Gmail/Outlook API.
136
+ * Throws `MessageNotFoundError` when the server says the message is gone
137
+ * (deleted from another device, for example). The caller uses that to
138
+ * delete the stale row locally instead of showing a generic error. */
136
139
  private fetchMessageBodyViaApi;
137
- /** Background body prefetch — download bodies for messages that don't have them */
140
+ /** Background body prefetch — download bodies for messages that don't have them.
141
+ * Server-side deletions (isNotFound) aren't errors here: we delete the
142
+ * stale row locally and keep going. Only unrelated errors (network,
143
+ * auth, rate limits) count against the error budget, and the budget is
144
+ * generous so a few transient failures don't kill the whole run. */
138
145
  private prefetchBodies;
139
146
  /** Get the body store for direct access */
140
147
  getBodyStore(): FileMessageStore;
@@ -42,6 +42,15 @@ function insertHeaderBeforeBody(raw, line) {
42
42
  const nl = m[0].startsWith("\r\n") ? "\r\n" : "\n";
43
43
  return raw.slice(0, m.index) + nl + line + raw.slice(m.index);
44
44
  }
45
+ /** Error thrown when a message body can't be fetched because the server says
46
+ * the message is gone (deleted from another device, expunged, etc.). The
47
+ * caller uses this to remove the stale local row instead of showing a
48
+ * generic "fetch failed" error to the user. */
49
+ function makeNotFoundError(accountId, folderId, uid) {
50
+ const err = new Error(`Message ${accountId}/${folderId}/${uid} not found on server`);
51
+ err.isNotFound = true;
52
+ return err;
53
+ }
45
54
  /** Extract full error detail with provenance */
46
55
  function imapError(err) {
47
56
  const msg = err.message || err.reason || err.code || (typeof err === "string" ? err : "");
@@ -1332,7 +1341,12 @@ export class ImapManager extends EventEmitter {
1332
1341
  const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
1333
1342
  await client.logout();
1334
1343
  client = null;
1335
- if (!msg?.source)
1344
+ if (!msg) {
1345
+ // IMAP server says the UID is gone — message was deleted
1346
+ // elsewhere. Raise NotFound so the caller can remove the row.
1347
+ throw makeNotFoundError(accountId, folderId, uid);
1348
+ }
1349
+ if (!msg.source)
1336
1350
  return null;
1337
1351
  const raw = Buffer.from(msg.source, "utf-8");
1338
1352
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
@@ -1340,6 +1354,15 @@ export class ImapManager extends EventEmitter {
1340
1354
  return raw;
1341
1355
  }
1342
1356
  catch (e) {
1357
+ if (e?.isNotFound) {
1358
+ if (client) {
1359
+ try {
1360
+ await client.logout();
1361
+ }
1362
+ catch { /* */ }
1363
+ }
1364
+ throw e;
1365
+ }
1343
1366
  console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
1344
1367
  if (client) {
1345
1368
  try {
@@ -1350,13 +1373,20 @@ export class ImapManager extends EventEmitter {
1350
1373
  return null;
1351
1374
  }
1352
1375
  }
1353
- /** Fetch message body via Gmail/Outlook API */
1376
+ /** Fetch message body via Gmail/Outlook API.
1377
+ * Throws `MessageNotFoundError` when the server says the message is gone
1378
+ * (deleted from another device, for example). The caller uses that to
1379
+ * delete the stale row locally instead of showing a generic error. */
1354
1380
  async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
1355
1381
  try {
1356
1382
  const api = this.getGmailProvider(accountId);
1357
1383
  const msg = await api.fetchOne(folderPath, uid, { source: true });
1358
1384
  await api.close();
1359
- if (!msg?.source)
1385
+ if (!msg) {
1386
+ // fetchOne returned null — message doesn't exist on the server anymore
1387
+ throw makeNotFoundError(accountId, folderId, uid);
1388
+ }
1389
+ if (!msg.source)
1360
1390
  return null;
1361
1391
  const raw = Buffer.from(msg.source, "utf-8");
1362
1392
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
@@ -1364,38 +1394,68 @@ export class ImapManager extends EventEmitter {
1364
1394
  return raw;
1365
1395
  }
1366
1396
  catch (e) {
1397
+ // Gmail API 404 → the message was deleted on the server
1398
+ if (e?.isNotFound || /Gmail API 404|404|not found/i.test(e?.message || "")) {
1399
+ throw makeNotFoundError(accountId, folderId, uid);
1400
+ }
1367
1401
  console.error(` [api] Body fetch error (${accountId}/${uid}): ${e.message}`);
1368
1402
  return null;
1369
1403
  }
1370
1404
  }
1371
- /** Background body prefetch — download bodies for messages that don't have them */
1405
+ /** Background body prefetch — download bodies for messages that don't have them.
1406
+ * Server-side deletions (isNotFound) aren't errors here: we delete the
1407
+ * stale row locally and keep going. Only unrelated errors (network,
1408
+ * auth, rate limits) count against the error budget, and the budget is
1409
+ * generous so a few transient failures don't kill the whole run. */
1372
1410
  async prefetchBodies(accountId) {
1373
- // Fetch ALL missing bodies in one pass — don't wait for next sync cycle
1374
1411
  let totalFetched = 0;
1412
+ let deleted = 0;
1375
1413
  let errors = 0;
1414
+ const ERROR_BUDGET = 20;
1376
1415
  while (true) {
1377
1416
  const missing = this.db.getMessagesWithoutBody(accountId, 100);
1378
1417
  if (missing.length === 0)
1379
1418
  break;
1380
- if (totalFetched === 0)
1419
+ if (totalFetched === 0 && deleted === 0)
1381
1420
  console.log(` [prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
1421
+ let madeProgress = false;
1382
1422
  for (const msg of missing) {
1383
1423
  try {
1384
1424
  const result = await this.fetchMessageBody(accountId, msg.folderId, msg.uid);
1385
- if (result)
1425
+ if (result) {
1386
1426
  totalFetched++;
1427
+ madeProgress = true;
1428
+ }
1387
1429
  }
1388
1430
  catch (e) {
1431
+ if (e?.isNotFound) {
1432
+ // Message deleted on the server — drop the stale row so
1433
+ // we stop re-asking. This also moves the loop forward
1434
+ // (next getMessagesWithoutBody call won't return it).
1435
+ try {
1436
+ this.db.deleteMessage(accountId, msg.uid);
1437
+ this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
1438
+ deleted++;
1439
+ madeProgress = true;
1440
+ }
1441
+ catch { /* ignore */ }
1442
+ continue;
1443
+ }
1389
1444
  errors++;
1390
- if (errors >= 3) {
1391
- console.error(` [prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
1445
+ if (errors >= ERROR_BUDGET) {
1446
+ console.error(` [prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached, ${deleted} pruned)`);
1392
1447
  return;
1393
1448
  }
1394
1449
  }
1395
1450
  }
1451
+ // Safety: if we made zero progress this iteration, bail — otherwise
1452
+ // we'd loop forever on rows that keep failing without isNotFound.
1453
+ if (!madeProgress)
1454
+ break;
1455
+ }
1456
+ if (totalFetched > 0 || deleted > 0) {
1457
+ console.log(` [prefetch] ${accountId}: ${totalFetched} bodies cached, ${deleted} stale rows pruned (done)`);
1396
1458
  }
1397
- if (totalFetched > 0)
1398
- console.log(` [prefetch] ${accountId}: ${totalFetched} bodies cached (done)`);
1399
1459
  }
1400
1460
  /** Get the body store for direct access */
1401
1461
  getBodyStore() {
@@ -141,13 +141,26 @@ export class MailxService {
141
141
  raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
142
142
  }
143
143
  catch (fetchErr) {
144
+ // Message was deleted from the server (another device, expunge, etc.) —
145
+ // drop the stale local row so the UI removes it instead of showing a
146
+ // confusing error. Throwing a tagged error lets the client react.
147
+ if (fetchErr?.isNotFound) {
148
+ try {
149
+ this.db.deleteMessage(accountId, envelope.uid);
150
+ this.db.recalcFolderCounts(envelope.folderId);
151
+ }
152
+ catch { /* ignore */ }
153
+ const err = new Error("Message was deleted from the server");
154
+ err.isNotFound = true;
155
+ throw err;
156
+ }
144
157
  return {
145
- ...envelope, bodyHtml: "", bodyText: `[Message body unavailable: ${fetchErr.message || "IMAP connection failed"}]`,
158
+ ...envelope, bodyHtml: "", bodyText: `[Message body unavailable: ${fetchErr.message || "connection failed"}]`,
146
159
  hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
147
160
  };
148
161
  }
149
162
  if (!raw) {
150
- bodyText = "[Message body not available — not cached locally and IMAP fetch failed. Try again or re-sync the folder.]";
163
+ bodyText = "[Message body not cached locally and the server fetch returned nothing. Try re-syncing the folder.]";
151
164
  }
152
165
  else {
153
166
  const parsed = await simpleParser(raw);