@bobfrankston/mailx 1.0.234 → 1.0.236

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.234",
3
+ "version": "1.0.236",
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.296",
27
+ "@bobfrankston/msger": "^0.1.298",
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.296",
81
+ "@bobfrankston/msger": "^0.1.298",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -132,7 +132,10 @@ 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
140
  /** Background body prefetch — download bodies for messages that don't have them */
138
141
  private prefetchBodies;
@@ -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 : "");
@@ -1009,9 +1018,15 @@ export class ImapManager extends EventEmitter {
1009
1018
  // the previous high. upsertMessage's primary-key dedup handles it.
1010
1019
  void highestUid;
1011
1020
  let stored = 0;
1012
- this.db.beginTransaction();
1013
- try {
1014
- for (const msg of msgs) {
1021
+ let errors = 0;
1022
+ // Don't wrap the whole batch in one transaction: a single bad row
1023
+ // would roll back the entire batch. E.g. a message with a malformed
1024
+ // Date header gave `new Date(rawStr).getTime() === NaN`, SQLite
1025
+ // coerced that to NULL, the NOT NULL constraint failed, and the
1026
+ // whole Gmail sync lost 200 messages per tick. Now each row runs
1027
+ // standalone — bad rows are logged and skipped.
1028
+ for (const msg of msgs) {
1029
+ try {
1015
1030
  const flags = [];
1016
1031
  if (msg.seen)
1017
1032
  flags.push("\\Seen");
@@ -1021,12 +1036,20 @@ export class ImapManager extends EventEmitter {
1021
1036
  flags.push("\\Answered");
1022
1037
  if (msg.draft)
1023
1038
  flags.push("\\Draft");
1039
+ // Sanitize date: reject NaN (from malformed RFC 822 Date headers)
1040
+ // and fall back to "now" so the message still lands in the DB.
1041
+ let dateMs = Date.now();
1042
+ if (msg.date instanceof Date) {
1043
+ const t = msg.date.getTime();
1044
+ if (Number.isFinite(t))
1045
+ dateMs = t;
1046
+ }
1024
1047
  this.db.upsertMessage({
1025
1048
  accountId, folderId, uid: msg.uid,
1026
1049
  messageId: msg.messageId || "",
1027
1050
  inReplyTo: msg.inReplyTo || "",
1028
1051
  references: msg.references || [],
1029
- date: msg.date instanceof Date ? msg.date.getTime() : Date.now(),
1052
+ date: dateMs,
1030
1053
  subject: msg.subject || "",
1031
1054
  from: toEmailAddress(msg.from?.[0] || {}),
1032
1055
  to: toEmailAddresses(msg.to || []),
@@ -1035,12 +1058,15 @@ export class ImapManager extends EventEmitter {
1035
1058
  });
1036
1059
  stored++;
1037
1060
  }
1038
- this.db.commitTransaction();
1039
- }
1040
- catch (e) {
1041
- this.db.rollbackTransaction();
1042
- console.error(` [api] storeApiMessages error: ${e.message}`);
1061
+ catch (e) {
1062
+ errors++;
1063
+ if (errors <= 3) {
1064
+ console.error(` [api] upsert ${accountId}/${folderId}/${msg.uid} (${msg.messageId}): ${e.message}`);
1065
+ }
1066
+ }
1043
1067
  }
1068
+ if (errors > 0)
1069
+ console.error(` [api] storeApiMessages: ${errors} of ${msgs.length} rows failed (${stored} stored)`);
1044
1070
  return stored;
1045
1071
  }
1046
1072
  /** Kill and recreate the persistent ops connection */
@@ -1315,7 +1341,12 @@ export class ImapManager extends EventEmitter {
1315
1341
  const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
1316
1342
  await client.logout();
1317
1343
  client = null;
1318
- 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)
1319
1350
  return null;
1320
1351
  const raw = Buffer.from(msg.source, "utf-8");
1321
1352
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
@@ -1323,6 +1354,15 @@ export class ImapManager extends EventEmitter {
1323
1354
  return raw;
1324
1355
  }
1325
1356
  catch (e) {
1357
+ if (e?.isNotFound) {
1358
+ if (client) {
1359
+ try {
1360
+ await client.logout();
1361
+ }
1362
+ catch { /* */ }
1363
+ }
1364
+ throw e;
1365
+ }
1326
1366
  console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
1327
1367
  if (client) {
1328
1368
  try {
@@ -1333,13 +1373,20 @@ export class ImapManager extends EventEmitter {
1333
1373
  return null;
1334
1374
  }
1335
1375
  }
1336
- /** 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. */
1337
1380
  async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
1338
1381
  try {
1339
1382
  const api = this.getGmailProvider(accountId);
1340
1383
  const msg = await api.fetchOne(folderPath, uid, { source: true });
1341
1384
  await api.close();
1342
- 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)
1343
1390
  return null;
1344
1391
  const raw = Buffer.from(msg.source, "utf-8");
1345
1392
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
@@ -1347,6 +1394,10 @@ export class ImapManager extends EventEmitter {
1347
1394
  return raw;
1348
1395
  }
1349
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
+ }
1350
1401
  console.error(` [api] Body fetch error (${accountId}/${uid}): ${e.message}`);
1351
1402
  return null;
1352
1403
  }
@@ -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);