@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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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:
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
|
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
|
|
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 || "
|
|
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
|
|
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);
|