@bobfrankston/mailx 1.0.256 → 1.0.260
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/bin/mailx.js +112 -0
- package/client/.msger-window.json +1 -1
- package/client/components/message-viewer.js +82 -7
- package/package.json +7 -6
- package/packages/mailx-imap/index.d.ts +6 -0
- package/packages/mailx-imap/index.js +100 -33
- package/packages/mailx-imap/package.json +2 -1
- package/packages/mailx-imap/providers/gmail-api.d.ts +5 -40
- package/packages/mailx-imap/providers/gmail-api.js +5 -336
- package/packages/mailx-imap/providers/types.d.ts +6 -59
- package/packages/mailx-imap/providers/types.js +5 -2
- package/packages/mailx-service/index.js +16 -2
- package/packages/mailx-store-web/android-bootstrap.js +8 -6
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -37
- package/packages/mailx-store-web/gmail-api-web.js +7 -298
- package/packages/mailx-store-web/imap-web-provider.d.ts +1 -1
- package/packages/mailx-store-web/imap-web-provider.js +2 -2
- package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
- package/packages/mailx-store-web/main-thread-host.js +287 -0
- package/packages/mailx-store-web/package.json +2 -1
- package/packages/mailx-store-web/provider-types.d.ts +4 -47
- package/packages/mailx-store-web/provider-types.js +3 -3
- package/packages/mailx-store-web/sync-manager.d.ts +61 -0
- package/packages/mailx-store-web/sync-manager.js +422 -0
- package/packages/mailx-store-web/worker-entry.d.ts +8 -0
- package/packages/mailx-store-web/worker-entry.js +187 -0
- package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
- package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
package/bin/mailx.js
CHANGED
|
@@ -28,6 +28,92 @@ const args = process.argv.slice(2);
|
|
|
28
28
|
function hasFlag(name) { return args.includes(`-${name}`) || args.includes(`--${name}`); }
|
|
29
29
|
const verbose = hasFlag("verbose");
|
|
30
30
|
const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process
|
|
31
|
+
// Read our own version once — used for the instance file + upgrade check below.
|
|
32
|
+
const __selfRoot = path.join(import.meta.dirname, "..");
|
|
33
|
+
const __selfVersion = (() => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(path.join(__selfRoot, "package.json"), "utf-8")).version || "unknown";
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
const __instanceFile = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx", "instance.json");
|
|
42
|
+
function readInstanceFile() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(__instanceFile, "utf-8");
|
|
45
|
+
const inst = JSON.parse(raw);
|
|
46
|
+
if (typeof inst.pid === "number" && typeof inst.version === "string")
|
|
47
|
+
return inst;
|
|
48
|
+
}
|
|
49
|
+
catch { /* missing or unreadable — treated as no instance */ }
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function writeInstanceFile(pid) {
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
|
|
55
|
+
fs.writeFileSync(__instanceFile, JSON.stringify({ pid, version: __selfVersion, startedAt: Date.now() }, null, 2));
|
|
56
|
+
}
|
|
57
|
+
catch { /* non-fatal */ }
|
|
58
|
+
}
|
|
59
|
+
function clearInstanceFile() {
|
|
60
|
+
try {
|
|
61
|
+
fs.unlinkSync(__instanceFile);
|
|
62
|
+
}
|
|
63
|
+
catch { /* ignore */ }
|
|
64
|
+
}
|
|
65
|
+
function pidAlive(pid) {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, 0);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Version-mismatch upgrade: if a daemon from an older version is running when
|
|
75
|
+
// the user types `mailx`, kill it so the new one can take over. Without this,
|
|
76
|
+
// a second invocation would silently no-op (daemon exists), leaving the user
|
|
77
|
+
// on an old UI with no indication that the install has been upgraded.
|
|
78
|
+
// Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
|
|
79
|
+
// the internal --daemon respawn.
|
|
80
|
+
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log"];
|
|
81
|
+
const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
|
|
82
|
+
if (!isDaemon && !__isCommandInvocation) {
|
|
83
|
+
const inst = readInstanceFile();
|
|
84
|
+
if (inst && pidAlive(inst.pid)) {
|
|
85
|
+
if (inst.version !== __selfVersion) {
|
|
86
|
+
console.log(`mailx: upgrading running daemon (PID ${inst.pid}) from v${inst.version} → v${__selfVersion}`);
|
|
87
|
+
try {
|
|
88
|
+
process.kill(inst.pid, "SIGTERM");
|
|
89
|
+
}
|
|
90
|
+
catch { /* already gone */ }
|
|
91
|
+
// Give it ~1.5s to exit gracefully, then verify
|
|
92
|
+
const deadline = Date.now() + 2000;
|
|
93
|
+
while (Date.now() < deadline && pidAlive(inst.pid)) {
|
|
94
|
+
const sab = new SharedArrayBuffer(4);
|
|
95
|
+
Atomics.wait(new Int32Array(sab), 0, 0, 100); // 100ms nap
|
|
96
|
+
}
|
|
97
|
+
if (pidAlive(inst.pid)) {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(inst.pid, "SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch { /* */ }
|
|
102
|
+
}
|
|
103
|
+
clearInstanceFile();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Same version already running — nothing to do. Print so the user
|
|
107
|
+
// knows why `mailx` seems to have done nothing.
|
|
108
|
+
console.log(`mailx v${__selfVersion} is already running (PID ${inst.pid}). Use mailx -kill to stop it.`);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (inst) {
|
|
113
|
+
// Stale instance file — PID is dead. Clean up.
|
|
114
|
+
clearInstanceFile();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
31
117
|
// Auto-detach: re-spawn as background process so terminal returns immediately
|
|
32
118
|
// Skip for: --verbose (want console), --daemon (already detached),
|
|
33
119
|
// and any command flags (setup, kill, test, etc.)
|
|
@@ -675,6 +761,24 @@ async function main() {
|
|
|
675
761
|
const home = process.env.USERPROFILE || process.env.HOME || ".";
|
|
676
762
|
const logDir = path.join(home, ".mailx", "logs");
|
|
677
763
|
fs.mkdirSync(logDir, { recursive: true });
|
|
764
|
+
// Prune logs older than LOG_RETENTION_DAYS on startup. Keep it simple:
|
|
765
|
+
// scan the dir, stat, delete. Cheap even with years of history.
|
|
766
|
+
const LOG_RETENTION_DAYS = 7;
|
|
767
|
+
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
|
768
|
+
try {
|
|
769
|
+
for (const name of fs.readdirSync(logDir)) {
|
|
770
|
+
if (!/^mailx-\d{4}-\d{2}-\d{2}\.log$/.test(name))
|
|
771
|
+
continue;
|
|
772
|
+
const full = path.join(logDir, name);
|
|
773
|
+
try {
|
|
774
|
+
const st = fs.statSync(full);
|
|
775
|
+
if (st.mtimeMs < cutoff)
|
|
776
|
+
fs.unlinkSync(full);
|
|
777
|
+
}
|
|
778
|
+
catch { /* ignore per-file error */ }
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch { /* ignore — log pruning is best-effort */ }
|
|
678
782
|
const logDate = new Date().toISOString().slice(0, 10);
|
|
679
783
|
const logPath = path.join(logDir, `mailx-${logDate}.log`);
|
|
680
784
|
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
|
@@ -716,6 +820,14 @@ async function main() {
|
|
|
716
820
|
size: { width: 1400, height: 900 },
|
|
717
821
|
escapeCloses: false,
|
|
718
822
|
});
|
|
823
|
+
// Register ourselves as the live instance so subsequent `mailx` invocations
|
|
824
|
+
// can detect version-mismatch and upgrade us (see top of file). Clear on
|
|
825
|
+
// any of: SIGINT, SIGTERM, normal exit.
|
|
826
|
+
writeInstanceFile(process.pid);
|
|
827
|
+
const __cleanupInstance = () => { clearInstanceFile(); };
|
|
828
|
+
process.once("exit", __cleanupInstance);
|
|
829
|
+
process.once("SIGINT", () => { __cleanupInstance(); process.exit(0); });
|
|
830
|
+
process.once("SIGTERM", () => { __cleanupInstance(); process.exit(0); });
|
|
719
831
|
// Handle requests from WebView → dispatch to MailxService
|
|
720
832
|
// Pass server version to dispatch so getVersion returns it
|
|
721
833
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":707,"y":89}
|
|
@@ -74,6 +74,7 @@ function installPreviewControls(iframe) {
|
|
|
74
74
|
const target = e.target;
|
|
75
75
|
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)))
|
|
76
76
|
return;
|
|
77
|
+
// Zoom is iframe-local — handle here, don't forward.
|
|
77
78
|
if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
|
|
78
79
|
e.preventDefault();
|
|
79
80
|
setZoom(previewZoom + ZOOM_STEP, doc);
|
|
@@ -89,11 +90,23 @@ function installPreviewControls(iframe) {
|
|
|
89
90
|
setZoom(1, doc);
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
+
// Forward EVERY keydown to the parent — no duplicated hotkey list.
|
|
94
|
+
// If the parent's handler calls preventDefault (because it owns the
|
|
95
|
+
// shortcut), dispatchEvent returns false, and we preventDefault on
|
|
96
|
+
// the iframe side too so the browser doesn't ALSO act on it
|
|
97
|
+
// (Ctrl+N otherwise pops a new browser window in some hosts).
|
|
98
|
+
// Single source of truth = app.ts hotkey handlers. Plain typing in
|
|
99
|
+
// the email body — letters, etc. — propagates with no parent
|
|
100
|
+
// handler matching, so dispatchEvent returns true and the iframe
|
|
101
|
+
// event is left alone.
|
|
102
|
+
const synth = new KeyboardEvent("keydown", {
|
|
93
103
|
key: e.key, code: e.code,
|
|
94
104
|
ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
|
|
95
105
|
bubbles: true, cancelable: true,
|
|
96
|
-
})
|
|
106
|
+
});
|
|
107
|
+
const allowDefault = document.dispatchEvent(synth);
|
|
108
|
+
if (!allowDefault)
|
|
109
|
+
e.preventDefault();
|
|
97
110
|
});
|
|
98
111
|
doc.addEventListener("wheel", (e) => {
|
|
99
112
|
if (!e.ctrlKey)
|
|
@@ -412,6 +425,60 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
412
425
|
loadRemote();
|
|
413
426
|
});
|
|
414
427
|
}
|
|
428
|
+
// Body fetch error — show banner above (empty) body instead of polluting
|
|
429
|
+
// the main content area with the error text. Transient errors get a retry.
|
|
430
|
+
if (msg.bodyError) {
|
|
431
|
+
const err = String(msg.bodyError);
|
|
432
|
+
const isTransient = !!msg.bodyErrorTransient;
|
|
433
|
+
const errBanner = document.createElement("div");
|
|
434
|
+
errBanner.className = "mv-error-banner";
|
|
435
|
+
errBanner.style.cssText = "margin:1rem;padding:0.75rem 1rem;border:1px solid var(--color-border);border-left:3px solid #d33;background:var(--color-bg-surface);border-radius:4px;font-size:var(--font-size-sm)";
|
|
436
|
+
errBanner.innerHTML = `
|
|
437
|
+
<div style="font-weight:600;margin-bottom:0.25rem;color:#d33">Body unavailable</div>
|
|
438
|
+
<div style="color:var(--color-text-muted);white-space:pre-wrap;word-break:break-word">${err.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] || c))}</div>
|
|
439
|
+
${isTransient ? `<button id="btn-retry-body" style="margin-top:0.5rem;padding:0.25rem 0.75rem">Retry</button>` : ""}
|
|
440
|
+
`;
|
|
441
|
+
bodyEl.appendChild(errBanner);
|
|
442
|
+
if (isTransient) {
|
|
443
|
+
errBanner.querySelector("#btn-retry-body")?.addEventListener("click", async () => {
|
|
444
|
+
errBanner.remove();
|
|
445
|
+
bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
|
|
446
|
+
try {
|
|
447
|
+
const retry = await getMessage(accountId, uid, false);
|
|
448
|
+
if (retry.bodyError) {
|
|
449
|
+
// Still failing — rebuild the error banner via recursive render.
|
|
450
|
+
showMessage(accountId, uid, folderId, specialUse).catch(() => { });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
bodyEl.innerHTML = "";
|
|
454
|
+
if (retry.bodyHtml) {
|
|
455
|
+
const iframe = document.createElement("iframe");
|
|
456
|
+
iframe.sandbox.add("allow-same-origin");
|
|
457
|
+
iframe.sandbox.add("allow-popups");
|
|
458
|
+
iframe.sandbox.add("allow-popups-to-escape-sandbox");
|
|
459
|
+
iframe.sandbox.add("allow-top-navigation-by-user-activation");
|
|
460
|
+
iframe.sandbox.add("allow-scripts");
|
|
461
|
+
iframe.srcdoc = wrapHtmlBody(retry.bodyHtml, retry.remoteAllowed);
|
|
462
|
+
bodyEl.appendChild(iframe);
|
|
463
|
+
installPreviewControls(iframe);
|
|
464
|
+
}
|
|
465
|
+
else if (retry.bodyText) {
|
|
466
|
+
const pre = document.createElement("pre");
|
|
467
|
+
pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
|
|
468
|
+
pre.innerHTML = linkifyText(retry.bodyText);
|
|
469
|
+
bodyEl.appendChild(pre);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch (e) {
|
|
476
|
+
bodyEl.innerHTML = `<div class="mv-empty">Retry failed: ${e.message}</div>`;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
415
482
|
// Body in sandboxed iframe
|
|
416
483
|
if (msg.bodyHtml) {
|
|
417
484
|
const iframe = document.createElement("iframe");
|
|
@@ -455,11 +522,19 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
455
522
|
e.preventDefault();
|
|
456
523
|
try {
|
|
457
524
|
const data = await getAttachment(accountId, uid, i, msg.folderId);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
525
|
+
const bridge = window._nativeBridge;
|
|
526
|
+
if (bridge?.openAttachment) {
|
|
527
|
+
// Android: blob URLs don't work in WebView. Pass base64
|
|
528
|
+
// to native bridge which saves to Downloads and opens
|
|
529
|
+
// with the system viewer.
|
|
530
|
+
await bridge.openAttachment(att.filename, data.contentType, data.content);
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
|
|
534
|
+
const blob = new Blob([bytes], { type: data.contentType });
|
|
535
|
+
const url = URL.createObjectURL(blob);
|
|
536
|
+
window.open(url, "_blank");
|
|
537
|
+
}
|
|
463
538
|
}
|
|
464
539
|
catch (err) {
|
|
465
540
|
console.error(`Attachment download failed: ${err.message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.260",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.19",
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.5",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.316",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"sql.js": "^1.14.1",
|
|
38
38
|
"@bobfrankston/tcp-transport": "^0.1.3",
|
|
39
39
|
"@bobfrankston/node-tcp-transport": "^0.1.1",
|
|
40
|
-
"@bobfrankston/smtp-direct": "^0.1.2"
|
|
40
|
+
"@bobfrankston/smtp-direct": "^0.1.2",
|
|
41
|
+
"@bobfrankston/mailx-sync": "file:../../../MailApps/mailx-sync"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@types/mailparser": "^3.4.6"
|
|
@@ -80,11 +81,11 @@
|
|
|
80
81
|
},
|
|
81
82
|
".transformedSnapshot": {
|
|
82
83
|
"dependencies": {
|
|
83
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
84
|
+
"@bobfrankston/iflow-direct": "^0.1.19",
|
|
84
85
|
"@bobfrankston/iflow-node": "^0.1.5",
|
|
85
86
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
86
87
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
87
|
-
"@bobfrankston/msger": "^0.1.
|
|
88
|
+
"@bobfrankston/msger": "^0.1.316",
|
|
88
89
|
"@capacitor/android": "^8.3.0",
|
|
89
90
|
"@capacitor/cli": "^8.3.0",
|
|
90
91
|
"@capacitor/core": "^8.3.0",
|
|
@@ -147,7 +147,13 @@ export declare class ImapManager extends EventEmitter {
|
|
|
147
147
|
* stale row locally and keep going. Only unrelated errors (network,
|
|
148
148
|
* auth, rate limits) count against the error budget, and the budget is
|
|
149
149
|
* generous so a few transient failures don't kill the whole run. */
|
|
150
|
+
/** Guard against concurrent prefetchBodies for the same account — mirror of
|
|
151
|
+
* `sendingAccounts`. Without this, every periodic-sync tick spawns a new
|
|
152
|
+
* prefetch session alongside any still in flight, blowing through Gmail's
|
|
153
|
+
* per-minute quota and racing on disk writes. One prefetch per account. */
|
|
154
|
+
private prefetchingAccounts;
|
|
150
155
|
private prefetchBodies;
|
|
156
|
+
private _prefetchBodies;
|
|
151
157
|
/** Get the body store for direct access */
|
|
152
158
|
getBodyStore(): FileMessageStore;
|
|
153
159
|
/** Bulk trash messages — local-first, single IMAP connection for all */
|
|
@@ -1259,11 +1259,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1259
1259
|
// which gives instant push — the STATUS poll is just a fallback
|
|
1260
1260
|
// in case IDLE silently dropped.
|
|
1261
1261
|
const isGmail = this.isGmailAccount(accountId);
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
|
|
1262
|
+
// IMAP accounts: IDLE gives instant push; STATUS poll is just a
|
|
1263
|
+
// safety net for silent IDLE drops — keep it infrequent.
|
|
1264
|
+
// Gmail accounts: no IDLE (Gmail API doesn't expose it), so the
|
|
1265
|
+
// quick poll IS the primary path to new-mail latency. Drop to 30s
|
|
1266
|
+
// so Gmail mail appears in ~15s average. Gmail quota budget is
|
|
1267
|
+
// huge (250 units/sec per user, 1.2B/day) — 120 polls/hour × 5
|
|
1268
|
+
// units ≈ 600/hour, trivial. Dovecot accounts stay at 5min to
|
|
1269
|
+
// respect connection limits (each poll = fresh connection).
|
|
1270
|
+
const interval = isGmail ? 30000 : 300000; // Gmail: 30s; IMAP: 5min
|
|
1267
1271
|
const timer = setInterval(() => {
|
|
1268
1272
|
this.quickInboxCheckAccount(accountId).catch(() => { });
|
|
1269
1273
|
}, interval);
|
|
@@ -1422,8 +1426,18 @@ export class ImapManager extends EventEmitter {
|
|
|
1422
1426
|
// fetchOne returned null — message doesn't exist on the server anymore
|
|
1423
1427
|
throw makeNotFoundError(accountId, folderId, uid);
|
|
1424
1428
|
}
|
|
1425
|
-
if (!msg.source)
|
|
1429
|
+
if (!msg.source) {
|
|
1430
|
+
// Gmail returned a message object but no raw bytes. Seen when:
|
|
1431
|
+
// (a) the message exists but is larger than the format=raw cap (~10MB),
|
|
1432
|
+
// (b) UID→Gmail-ID resolution picked a collision and the target
|
|
1433
|
+
// exists only as a stub, or (c) the listMessageIds top-1000
|
|
1434
|
+
// didn't include our UID and fetchOne returned null above —
|
|
1435
|
+
// wait, that would hit the !msg branch. So (a)/(b) remain.
|
|
1436
|
+
// Log enough to distinguish; surface the reason up via a non-null
|
|
1437
|
+
// return so the UI stops showing a generic "fetch returned nothing".
|
|
1438
|
+
console.error(` [api] Body fetch empty source (${accountId}/${uid}): Gmail returned no raw body — likely too-large-for-format-raw or UID hash collision`);
|
|
1426
1439
|
return null;
|
|
1440
|
+
}
|
|
1427
1441
|
const raw = Buffer.from(msg.source, "utf-8");
|
|
1428
1442
|
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
1429
1443
|
this.db.updateBodyPath(accountId, uid, bodyPath);
|
|
@@ -1443,7 +1457,23 @@ export class ImapManager extends EventEmitter {
|
|
|
1443
1457
|
* stale row locally and keep going. Only unrelated errors (network,
|
|
1444
1458
|
* auth, rate limits) count against the error budget, and the budget is
|
|
1445
1459
|
* generous so a few transient failures don't kill the whole run. */
|
|
1460
|
+
/** Guard against concurrent prefetchBodies for the same account — mirror of
|
|
1461
|
+
* `sendingAccounts`. Without this, every periodic-sync tick spawns a new
|
|
1462
|
+
* prefetch session alongside any still in flight, blowing through Gmail's
|
|
1463
|
+
* per-minute quota and racing on disk writes. One prefetch per account. */
|
|
1464
|
+
prefetchingAccounts = new Set();
|
|
1446
1465
|
async prefetchBodies(accountId) {
|
|
1466
|
+
if (this.prefetchingAccounts.has(accountId))
|
|
1467
|
+
return;
|
|
1468
|
+
this.prefetchingAccounts.add(accountId);
|
|
1469
|
+
try {
|
|
1470
|
+
await this._prefetchBodies(accountId);
|
|
1471
|
+
}
|
|
1472
|
+
finally {
|
|
1473
|
+
this.prefetchingAccounts.delete(accountId);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
async _prefetchBodies(accountId) {
|
|
1447
1477
|
const counters = { totalFetched: 0, deleted: 0, errors: 0 };
|
|
1448
1478
|
const ERROR_BUDGET = 20;
|
|
1449
1479
|
const RATE_LIMIT_PAUSE_MS = 30000;
|
|
@@ -1487,6 +1517,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1487
1517
|
continue;
|
|
1488
1518
|
const received = new Set();
|
|
1489
1519
|
const pending = [];
|
|
1520
|
+
let batchSucceeded = false;
|
|
1490
1521
|
try {
|
|
1491
1522
|
await api.fetchBodiesBatch(folder.path, uidsInFolder, (uid, source) => {
|
|
1492
1523
|
received.add(uid);
|
|
@@ -1503,6 +1534,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1503
1534
|
}
|
|
1504
1535
|
})());
|
|
1505
1536
|
});
|
|
1537
|
+
batchSucceeded = true;
|
|
1506
1538
|
}
|
|
1507
1539
|
catch (e) {
|
|
1508
1540
|
const isRate = /429|rate|too many/i.test(String(e?.message || ""));
|
|
@@ -1516,20 +1548,24 @@ export class ImapManager extends EventEmitter {
|
|
|
1516
1548
|
}
|
|
1517
1549
|
}
|
|
1518
1550
|
await Promise.all(pending);
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1551
|
+
// CRITICAL: only prune as "server-deleted" when the batch
|
|
1552
|
+
// actually completed. If the batch threw (403, 429, network
|
|
1553
|
+
// error, etc.) NOTHING was received, and treating every
|
|
1554
|
+
// requested UID as deleted silently wipes 100 messages per
|
|
1555
|
+
// batch. That's a data-loss bug. Earlier version did this
|
|
1556
|
+
// and pruned 296 messages on a 403 auth error.
|
|
1557
|
+
if (batchSucceeded) {
|
|
1558
|
+
for (const uid of uidsInFolder) {
|
|
1559
|
+
if (received.has(uid))
|
|
1560
|
+
continue;
|
|
1561
|
+
try {
|
|
1562
|
+
this.db.deleteMessage(accountId, uid);
|
|
1563
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
1564
|
+
counters.deleted++;
|
|
1565
|
+
madeProgress = true;
|
|
1566
|
+
}
|
|
1567
|
+
catch { /* ignore */ }
|
|
1531
1568
|
}
|
|
1532
|
-
catch { /* ignore */ }
|
|
1533
1569
|
}
|
|
1534
1570
|
if (counters.errors >= ERROR_BUDGET)
|
|
1535
1571
|
break;
|
|
@@ -1571,6 +1607,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1571
1607
|
// batch command finishes. This keeps streaming throughput high while
|
|
1572
1608
|
// still giving us a single await point for progress accounting.
|
|
1573
1609
|
const pending = [];
|
|
1610
|
+
let batchSucceeded = false;
|
|
1574
1611
|
try {
|
|
1575
1612
|
await client.fetchBodiesBatch(folder.path, uids, (uid, source) => {
|
|
1576
1613
|
received.add(uid);
|
|
@@ -1588,6 +1625,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1588
1625
|
}
|
|
1589
1626
|
})());
|
|
1590
1627
|
});
|
|
1628
|
+
batchSucceeded = true;
|
|
1591
1629
|
}
|
|
1592
1630
|
catch (e) {
|
|
1593
1631
|
console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${e.message}`);
|
|
@@ -1596,19 +1634,21 @@ export class ImapManager extends EventEmitter {
|
|
|
1596
1634
|
break;
|
|
1597
1635
|
}
|
|
1598
1636
|
await Promise.all(pending);
|
|
1599
|
-
//
|
|
1600
|
-
//
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1637
|
+
// CRITICAL: only prune when the batch actually completed.
|
|
1638
|
+
// A thrown batch means NOTHING was received and we must
|
|
1639
|
+
// not treat absence-from-received as server-deletion.
|
|
1640
|
+
if (batchSucceeded)
|
|
1641
|
+
for (const uid of uids) {
|
|
1642
|
+
if (received.has(uid))
|
|
1643
|
+
continue;
|
|
1644
|
+
try {
|
|
1645
|
+
this.db.deleteMessage(accountId, uid);
|
|
1646
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
1647
|
+
counters.deleted++;
|
|
1648
|
+
madeProgress = true;
|
|
1649
|
+
}
|
|
1650
|
+
catch { /* ignore */ }
|
|
1609
1651
|
}
|
|
1610
|
-
catch { /* ignore */ }
|
|
1611
|
-
}
|
|
1612
1652
|
}
|
|
1613
1653
|
}
|
|
1614
1654
|
finally {
|
|
@@ -1901,8 +1941,35 @@ export class ImapManager extends EventEmitter {
|
|
|
1901
1941
|
console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
|
|
1902
1942
|
}
|
|
1903
1943
|
}
|
|
1904
|
-
// Append new draft
|
|
1905
|
-
|
|
1944
|
+
// Append new draft. If the server returns [TRYCREATE] (RFC 3501 §7.1),
|
|
1945
|
+
// the folder doesn't exist on the server even though mailx's DB has
|
|
1946
|
+
// it — happens when the folder was never created, or when the local
|
|
1947
|
+
// special-folder detection latched onto a path that doesn't match
|
|
1948
|
+
// the server's actual name. Create it then retry. Logs the path so
|
|
1949
|
+
// we can diagnose a mis-detected Drafts folder.
|
|
1950
|
+
let result;
|
|
1951
|
+
try {
|
|
1952
|
+
result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
|
|
1953
|
+
}
|
|
1954
|
+
catch (e) {
|
|
1955
|
+
const msg = String(e?.message || e);
|
|
1956
|
+
if (/TRYCREATE/i.test(msg)) {
|
|
1957
|
+
console.log(` [drafts] APPEND got TRYCREATE for "${drafts.path}" — creating folder and retrying`);
|
|
1958
|
+
try {
|
|
1959
|
+
await client.createmailbox(drafts.path);
|
|
1960
|
+
}
|
|
1961
|
+
catch (ce) {
|
|
1962
|
+
// "already exists" is benign; others we surface
|
|
1963
|
+
if (!/already exists/i.test(String(ce?.message || ""))) {
|
|
1964
|
+
console.error(` [drafts] Folder create failed for "${drafts.path}": ${ce.message}`);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
|
|
1968
|
+
}
|
|
1969
|
+
else {
|
|
1970
|
+
throw e;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1906
1973
|
// APPENDUID returns the UID directly; imapflow returns { destination, uid }
|
|
1907
1974
|
const uid = typeof result === "number" ? result : result?.uid || null;
|
|
1908
1975
|
return uid;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
|
|
16
16
|
"@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
|
|
17
17
|
"@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
|
|
18
|
+
"@bobfrankston/mailx-sync": "file:../../../MailApps/mailx-sync",
|
|
18
19
|
"@bobfrankston/oauthsupport": "file:../../../../projects/oauth/oauthsupport"
|
|
19
20
|
},
|
|
20
21
|
"repository": {
|
|
@@ -1,43 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Back-compat re-export. The canonical Gmail provider lives in
|
|
3
|
+
* @bobfrankston/mailx-sync. mailx-imap re-exports it under its old name so
|
|
4
|
+
* call sites here keep compiling. Both desktop (this package) and Android
|
|
5
|
+
* (mailx-store-web) consume the same single implementation now.
|
|
4
6
|
*/
|
|
5
|
-
|
|
6
|
-
export declare class GmailApiProvider implements MailProvider {
|
|
7
|
-
private tokenProvider;
|
|
8
|
-
constructor(tokenProvider: () => Promise<string>);
|
|
9
|
-
private fetch;
|
|
10
|
-
listFolders(): Promise<ProviderFolder[]>;
|
|
11
|
-
/** List message IDs matching a query, handling pagination.
|
|
12
|
-
* IMPORTANT: on any error we throw — do NOT return a partial list, because
|
|
13
|
-
* callers use this for sync reconciliation and a short list would delete
|
|
14
|
-
* real messages from the local DB. Returning [] silently caused the
|
|
15
|
-
* "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
|
|
16
|
-
private listMessageIds;
|
|
17
|
-
/** Batch-fetch message metadata or full content */
|
|
18
|
-
private batchFetch;
|
|
19
|
-
/** Parse a Gmail API message response into ProviderMessage */
|
|
20
|
-
private parseMessage;
|
|
21
|
-
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
22
|
-
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
23
|
-
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
24
|
-
/** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
|
|
25
|
-
* Lists the label once, builds UID→ID map, then streams bodies through
|
|
26
|
-
* `onBody` with bounded concurrency (lets Gmail's HTTP/2 stream multiplex;
|
|
27
|
-
* `fetch()`'s built-in 429/5xx retry handles backoff automatically).
|
|
28
|
-
*
|
|
29
|
-
* NOTE: Gmail's model is labels, not folders — a single message can be in
|
|
30
|
-
* many labels. Treating each label as a folder causes duplicate fetches
|
|
31
|
-
* across labels. Proper fix tracked as separate TODO ("Gmail label-native
|
|
32
|
-
* model"). For now we mirror the IMAP folder grouping, accepting duplicate
|
|
33
|
-
* fetches of multi-labeled messages. */
|
|
34
|
-
fetchBodiesBatch(folder: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
|
|
35
|
-
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
36
|
-
getUids(folder: string): Promise<number[]>;
|
|
37
|
-
close(): Promise<void>;
|
|
38
|
-
/** Map folder path to Gmail label query term */
|
|
39
|
-
private folderToLabel;
|
|
40
|
-
/** Format date for Gmail query (YYYY/MM/DD) */
|
|
41
|
-
private formatDate;
|
|
42
|
-
}
|
|
7
|
+
export { GmailApiProvider } from "@bobfrankston/mailx-sync";
|
|
43
8
|
//# sourceMappingURL=gmail-api.d.ts.map
|