@bobfrankston/mailx 1.0.339 → 1.0.340
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 +50 -0
- package/client/app.js +54 -0
- package/client/compose/compose.js +26 -18
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +24 -0
- package/packages/mailx-imap/index.js +113 -4
- package/packages/mailx-service/index.d.ts +2 -0
- package/packages/mailx-service/index.js +30 -5
- package/packages/mailx-service/jsonrpc.js +2 -0
package/bin/mailx.js
CHANGED
|
@@ -909,6 +909,53 @@ async function main() {
|
|
|
909
909
|
}
|
|
910
910
|
}
|
|
911
911
|
const db = new MailxDB(getConfigDir());
|
|
912
|
+
// Auto-create the sending/ recovery README on every startup. Stays in
|
|
913
|
+
// sync with the running version of mailx; user can ignore once the
|
|
914
|
+
// disk-staging fallback is no longer needed.
|
|
915
|
+
try {
|
|
916
|
+
const sendingDir = path.join(getConfigDir(), "sending");
|
|
917
|
+
fs.mkdirSync(sendingDir, { recursive: true });
|
|
918
|
+
const readmePath = path.join(sendingDir, "README.md");
|
|
919
|
+
const readmeBody = `# \`~/.mailx/sending/\` and \`~/.mailx/outbox/\` — outgoing-mail staging
|
|
920
|
+
|
|
921
|
+
Auto-generated by mailx on startup. Manual recovery reference for when mailx is broken or you need to feed an outgoing message into another mail program.
|
|
922
|
+
|
|
923
|
+
## Layout
|
|
924
|
+
|
|
925
|
+
\`\`\`
|
|
926
|
+
~/.mailx/
|
|
927
|
+
├── outbox/<account>/
|
|
928
|
+
│ └── *.ltr ← THE QUEUE. Worker scans every 10s, sends, deletes on success.
|
|
929
|
+
└── sending/<account>/
|
|
930
|
+
├── editing/ ← Last 3 draft autosaves while composing.
|
|
931
|
+
├── queued/ ← Manual drop-in / crash-recovery copies.
|
|
932
|
+
└── sent/ ← Audit trail of successfully sent messages.
|
|
933
|
+
\`\`\`
|
|
934
|
+
|
|
935
|
+
In-flight files are atomically renamed to \`<file>.sending-<host>-<pid>\` while the worker is processing them — same-machine claim so two mailx instances don't double-send. Stale claims (dead PIDs on this host) are recovered on the next tick.
|
|
936
|
+
|
|
937
|
+
## Manual fallback
|
|
938
|
+
|
|
939
|
+
- **mailx is dead, need to send a draft** — most recent file in \`sending/<account>/editing/\` is a complete RFC 822 message; copy the body into another mail client and resend.
|
|
940
|
+
- **Feed a raw .eml to mailx** — drop into \`sending/<account>/queued/\`. Picked up within 10s.
|
|
941
|
+
- **mailx says queued but server doesn't have it** — look in \`outbox/<account>/\`. \`.ltr\` still there → worker hasn't sent yet (check \`~/.mailx/logs/\`). \`.sending-<host>-<pid>\` → in flight. Gone → success.
|
|
942
|
+
|
|
943
|
+
## Format
|
|
944
|
+
|
|
945
|
+
RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable in a text editor). Every message carries \`Message-ID:\` for cross-device dedup; \`X-Mailx-Retry\` marks retry attempts.
|
|
946
|
+
`;
|
|
947
|
+
// Only rewrite if content drifted (avoids gratuitous mtime updates).
|
|
948
|
+
let existing = "";
|
|
949
|
+
try {
|
|
950
|
+
existing = fs.readFileSync(readmePath, "utf-8");
|
|
951
|
+
}
|
|
952
|
+
catch { /* missing */ }
|
|
953
|
+
if (existing !== readmeBody)
|
|
954
|
+
fs.writeFileSync(readmePath, readmeBody);
|
|
955
|
+
}
|
|
956
|
+
catch (e) {
|
|
957
|
+
console.error(` [readme] Could not write sending README: ${e?.message || e}`);
|
|
958
|
+
}
|
|
912
959
|
const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
|
|
913
960
|
const imapManager = new ImapManager(db, () => new NodeTcpTransport());
|
|
914
961
|
// Native client is the only option (iflow-direct)
|
|
@@ -1119,6 +1166,9 @@ async function main() {
|
|
|
1119
1166
|
imapManager.on("configChanged", (filename) => {
|
|
1120
1167
|
handle.send({ _event: "configChanged", type: "configChanged", filename });
|
|
1121
1168
|
});
|
|
1169
|
+
imapManager.on("outboxStatus", (status) => {
|
|
1170
|
+
handle.send({ _event: "outboxStatus", type: "outboxStatus", ...status });
|
|
1171
|
+
});
|
|
1122
1172
|
// syncComplete drives the folder-tree refresh that picks up newly-discovered
|
|
1123
1173
|
// folders on first run (Gmail accounts have no folders in the DB until the
|
|
1124
1174
|
// first sync fetches the labels). Without this forward, the UI shows the
|
package/client/app.js
CHANGED
|
@@ -1335,6 +1335,9 @@ onWsEvent((event) => {
|
|
|
1335
1335
|
statusSync.textContent = `Error: ${event.message}`;
|
|
1336
1336
|
showAlert(event.message, "ws-error");
|
|
1337
1337
|
break;
|
|
1338
|
+
case "outboxStatus":
|
|
1339
|
+
renderOutboxStatus(event);
|
|
1340
|
+
break;
|
|
1338
1341
|
case "accountError": {
|
|
1339
1342
|
// Show actual error + hint in banner
|
|
1340
1343
|
const msg = `${event.accountId}: ${event.error}`;
|
|
@@ -2208,6 +2211,57 @@ else
|
|
|
2208
2211
|
}
|
|
2209
2212
|
}
|
|
2210
2213
|
}, 5000);
|
|
2214
|
+
// ── Outbox queue indicator (status-queue span) ──
|
|
2215
|
+
// Event-driven in IPC mode (service pushes outboxStatus on every mutation).
|
|
2216
|
+
// Plus a 15s poll safety net for both modes so a missed event doesn't leave
|
|
2217
|
+
// the user staring at stale numbers. Idempotent — renderOutboxStatus just
|
|
2218
|
+
// overwrites the text.
|
|
2219
|
+
function renderOutboxStatus(s) {
|
|
2220
|
+
const el = document.getElementById("status-queue");
|
|
2221
|
+
if (!el)
|
|
2222
|
+
return;
|
|
2223
|
+
if (!s || !s.total || s.total === 0) {
|
|
2224
|
+
el.textContent = "";
|
|
2225
|
+
el.title = "";
|
|
2226
|
+
el.style.color = "";
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
const parts = [`✉ ${s.total} queued`];
|
|
2230
|
+
if (s.claimed > 0)
|
|
2231
|
+
parts.push(`${s.claimed} sending`);
|
|
2232
|
+
if (s.retrying > 0)
|
|
2233
|
+
parts.push(`${s.retrying} retrying (×${s.maxAttempts})`);
|
|
2234
|
+
if (s.oldestAgeSec >= 60) {
|
|
2235
|
+
const age = s.oldestAgeSec >= 3600
|
|
2236
|
+
? `${Math.floor(s.oldestAgeSec / 3600)}h`
|
|
2237
|
+
: `${Math.floor(s.oldestAgeSec / 60)}m`;
|
|
2238
|
+
parts.push(`oldest ${age}`);
|
|
2239
|
+
}
|
|
2240
|
+
el.textContent = parts.join(" · ");
|
|
2241
|
+
const perAcct = s.perAccount || {};
|
|
2242
|
+
const detail = Object.keys(perAcct).sort().map(a => `${a}: ${perAcct[a].total} total, ${perAcct[a].claimed} sending, ${perAcct[a].retrying} retrying`).join("\n");
|
|
2243
|
+
el.title = detail || "";
|
|
2244
|
+
// Orange when retrying, red when stuck >5min, else muted.
|
|
2245
|
+
el.style.color = s.oldestAgeSec > 300 ? "oklch(0.65 0.2 25)"
|
|
2246
|
+
: s.retrying > 0 ? "oklch(0.75 0.15 60)"
|
|
2247
|
+
: "";
|
|
2248
|
+
}
|
|
2249
|
+
setInterval(async () => {
|
|
2250
|
+
try {
|
|
2251
|
+
const { getOutboxStatus } = await import("./lib/api-client.js");
|
|
2252
|
+
const s = await getOutboxStatus();
|
|
2253
|
+
renderOutboxStatus(s);
|
|
2254
|
+
}
|
|
2255
|
+
catch { /* service unreachable */ }
|
|
2256
|
+
}, 15000);
|
|
2257
|
+
// First read on startup so the bar isn't blank.
|
|
2258
|
+
(async () => {
|
|
2259
|
+
try {
|
|
2260
|
+
const { getOutboxStatus } = await import("./lib/api-client.js");
|
|
2261
|
+
renderOutboxStatus(await getOutboxStatus());
|
|
2262
|
+
}
|
|
2263
|
+
catch { /* */ }
|
|
2264
|
+
})();
|
|
2211
2265
|
console.log("mailx client initialized, location:", location.href);
|
|
2212
2266
|
updateNewMessageCount();
|
|
2213
2267
|
// ── Midnight refresh — update date display when day changes ──
|
|
@@ -528,7 +528,7 @@ function scheduleDraftSave() {
|
|
|
528
528
|
document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
529
529
|
const btn = document.getElementById("btn-send");
|
|
530
530
|
btn.disabled = true;
|
|
531
|
-
btn.textContent = "Sending
|
|
531
|
+
btn.textContent = "Sending…";
|
|
532
532
|
const body = {
|
|
533
533
|
from: getFromAccountId(),
|
|
534
534
|
fromAddress: getFromAddress(),
|
|
@@ -540,11 +540,22 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
540
540
|
bodyText: editor.getText(),
|
|
541
541
|
attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
|
|
542
542
|
};
|
|
543
|
+
console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
|
|
544
|
+
// Live countdown so the user sees the IPC is alive. Hard timeout is 120s.
|
|
545
|
+
const sendStart = Date.now();
|
|
546
|
+
const sendTick = setInterval(() => {
|
|
547
|
+
const sec = Math.floor((Date.now() - sendStart) / 1000);
|
|
548
|
+
if (sec < 2)
|
|
549
|
+
btn.textContent = "Sending…";
|
|
550
|
+
else if (sec < 10)
|
|
551
|
+
btn.textContent = `Queueing… (${sec}s)`;
|
|
552
|
+
else
|
|
553
|
+
btn.textContent = `Still working… (${sec}s of 120s)`;
|
|
554
|
+
}, 500);
|
|
543
555
|
try {
|
|
544
556
|
await sendMessage(body);
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
// so any orphaned drafts with the same stable ID are cleaned up too.
|
|
557
|
+
clearInterval(sendTick);
|
|
558
|
+
console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
|
|
548
559
|
if (draftTimer) {
|
|
549
560
|
clearInterval(draftTimer);
|
|
550
561
|
draftTimer = null;
|
|
@@ -555,23 +566,20 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
555
566
|
closeCompose();
|
|
556
567
|
}
|
|
557
568
|
catch (e) {
|
|
569
|
+
clearInterval(sendTick);
|
|
570
|
+
const msg = e?.message || String(e);
|
|
571
|
+
console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
|
|
558
572
|
btn.disabled = false;
|
|
559
573
|
btn.textContent = "Send";
|
|
560
|
-
const msg = e?.message || String(e);
|
|
561
|
-
// Distinguish the IPC-timeout case from real send failures. The
|
|
562
|
-
// service-side send() queues the message to the local DB synchronously
|
|
563
|
-
// before attempting any IMAP/SMTP work — so if the IPC reached Node at
|
|
564
|
-
// all, the message is queued and the background worker will retry it
|
|
565
|
-
// with backoff (X-Mailx-Retry header, 60s settling delay, up to 10
|
|
566
|
-
// attempts). Treating that as a failure that demands a re-click leads
|
|
567
|
-
// to duplicate sends. Tell the user honestly: "probably queued, check
|
|
568
|
-
// Outbox before retrying."
|
|
569
574
|
if (msg.startsWith("mailxapi timeout")) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
+
// Disk-first queueing means the .ltr file should be on disk even
|
|
576
|
+
// if the IPC reply itself was lost. Tell the user where to look.
|
|
577
|
+
alert(`Send IPC timed out after 120s.\n\n` +
|
|
578
|
+
`If the disk-first queue was reached, the message is in:\n` +
|
|
579
|
+
`~/.mailx/outbox/${getFromAccountId()}/*.ltr\n\n` +
|
|
580
|
+
`Don't click Send again — it could double-send. Check that ` +
|
|
581
|
+
`directory first (and the log) before deciding.\n\n` +
|
|
582
|
+
`Your draft is preserved either way.`);
|
|
575
583
|
}
|
|
576
584
|
else {
|
|
577
585
|
alert(`Send failed: ${msg}`);
|
package/client/lib/api-client.js
CHANGED
|
@@ -62,6 +62,9 @@ export function reauthenticate(accountId) {
|
|
|
62
62
|
export function getSyncPending() {
|
|
63
63
|
return ipc().getSyncPending();
|
|
64
64
|
}
|
|
65
|
+
export function getOutboxStatus() {
|
|
66
|
+
return ipc().getOutboxStatus();
|
|
67
|
+
}
|
|
65
68
|
export function searchContacts(query) {
|
|
66
69
|
return ipc().searchContacts(query);
|
|
67
70
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -140,6 +140,7 @@
|
|
|
140
140
|
syncAll: function() { return callNode("syncAll"); },
|
|
141
141
|
syncAccount: function(accountId) { return callNode("syncAccount", { accountId: accountId }); },
|
|
142
142
|
getSyncPending: function() { return callNode("getSyncPending"); },
|
|
143
|
+
getOutboxStatus: function() { return callNode("getOutboxStatus"); },
|
|
143
144
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
144
145
|
|
|
145
146
|
// Bulk operations
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.340",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
27
|
"@bobfrankston/msger": "^0.1.344",
|
|
28
|
-
"@bobfrankston/mailx-host": "^0.1.
|
|
28
|
+
"@bobfrankston/mailx-host": "^0.1.4",
|
|
29
29
|
"@capacitor/android": "^8.3.0",
|
|
30
30
|
"@capacitor/cli": "^8.3.0",
|
|
31
31
|
"@capacitor/core": "^8.3.0",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
90
90
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
91
91
|
"@bobfrankston/msger": "^0.1.344",
|
|
92
|
-
"@bobfrankston/mailx-host": "^0.1.
|
|
92
|
+
"@bobfrankston/mailx-host": "^0.1.4",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
95
95
|
"@capacitor/core": "^8.3.0",
|
|
@@ -23,6 +23,24 @@ export interface ImapManagerEvents {
|
|
|
23
23
|
* the UI flip a row's "not-downloaded" indicator without re-rendering. */
|
|
24
24
|
bodyCached: (accountId: string, uid: number) => void;
|
|
25
25
|
syncActionFailed: (accountId: string, action: string, uid: number, error: string) => void;
|
|
26
|
+
/** Fired whenever the outbox queue depth or state changes (file added,
|
|
27
|
+
* file sent and removed, retry attempted). Lets the UI show a persistent
|
|
28
|
+
* queue-status indicator without polling. Aggregate status across all
|
|
29
|
+
* accounts is included so the listener doesn't have to reassemble it. */
|
|
30
|
+
outboxStatus: (status: OutboxStatus) => void;
|
|
31
|
+
}
|
|
32
|
+
/** Per-account outbox queue breakdown, plus totals for the UI. */
|
|
33
|
+
export interface OutboxStatus {
|
|
34
|
+
total: number;
|
|
35
|
+
retrying: number;
|
|
36
|
+
claimed: number;
|
|
37
|
+
oldestAgeSec: number;
|
|
38
|
+
maxAttempts: number;
|
|
39
|
+
perAccount: Record<string, {
|
|
40
|
+
total: number;
|
|
41
|
+
retrying: number;
|
|
42
|
+
claimed: number;
|
|
43
|
+
}>;
|
|
26
44
|
}
|
|
27
45
|
export declare class ImapManager extends EventEmitter {
|
|
28
46
|
private configs;
|
|
@@ -231,6 +249,12 @@ export declare class ImapManager extends EventEmitter {
|
|
|
231
249
|
* sync_actions "send" branch was removed because it duplicated the same
|
|
232
250
|
* work and risked double-send when both paths fired on the same message. */
|
|
233
251
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
252
|
+
/** Scan the local outbox + sending/queued dirs and return counts + age.
|
|
253
|
+
* Cheap — a handful of readdir + head-read per file. Called by both the
|
|
254
|
+
* polling UI (status bar) and emitted as an event after queue mutations. */
|
|
255
|
+
getOutboxStatus(): OutboxStatus;
|
|
256
|
+
/** Emit outboxStatus now. Call after any queue mutation. */
|
|
257
|
+
private emitOutboxStatus;
|
|
234
258
|
/** Guard against concurrent processSendActions for the same account */
|
|
235
259
|
private sendingAccounts;
|
|
236
260
|
/** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
|
|
@@ -2453,15 +2453,122 @@ export class ImapManager extends EventEmitter {
|
|
|
2453
2453
|
* sync_actions "send" branch was removed because it duplicated the same
|
|
2454
2454
|
* work and risked double-send when both paths fired on the same message. */
|
|
2455
2455
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
2456
|
+
// Loud logging so a "vanished message" report is diagnosable from the log alone.
|
|
2456
2457
|
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
2457
|
-
|
|
2458
|
+
try {
|
|
2459
|
+
fs.mkdirSync(outboxDir, { recursive: true });
|
|
2460
|
+
}
|
|
2461
|
+
catch (e) {
|
|
2462
|
+
console.error(` [outbox] FAIL mkdirSync ${outboxDir}: ${e?.message || e}`);
|
|
2463
|
+
throw new Error(`Cannot create outbox dir ${outboxDir}: ${e?.message || e}`);
|
|
2464
|
+
}
|
|
2458
2465
|
const now = new Date();
|
|
2459
2466
|
const pad2 = (n) => String(n).padStart(2, "0");
|
|
2460
2467
|
const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
|
|
2461
2468
|
const filePath = path.join(outboxDir, filename);
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2469
|
+
try {
|
|
2470
|
+
fs.writeFileSync(filePath, rawMessage);
|
|
2471
|
+
}
|
|
2472
|
+
catch (e) {
|
|
2473
|
+
console.error(` [outbox] FAIL writeFileSync ${filePath}: ${e?.message || e}`);
|
|
2474
|
+
throw new Error(`Cannot write outbox file ${filePath}: ${e?.message || e}`);
|
|
2475
|
+
}
|
|
2476
|
+
// Immediate readback verification — if this DOESN'T print, the user's
|
|
2477
|
+
// "neither in outbox nor file system" report has a real explanation.
|
|
2478
|
+
const written = fs.existsSync(filePath);
|
|
2479
|
+
const size = written ? fs.statSync(filePath).size : 0;
|
|
2480
|
+
console.log(` [outbox] WROTE ${filePath} (${size} bytes, exists=${written})`);
|
|
2481
|
+
this.emitOutboxStatus();
|
|
2482
|
+
// CRITICAL: defer to next tick. processLocalQueue runs ~30+ lines
|
|
2483
|
+
// of synchronous fs work BEFORE its first await — calling it inline
|
|
2484
|
+
// blocks the IPC ack on all that work.
|
|
2485
|
+
setImmediate(() => {
|
|
2486
|
+
this.processLocalQueue(accountId)
|
|
2487
|
+
.catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`))
|
|
2488
|
+
.finally(() => this.emitOutboxStatus());
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
/** Scan the local outbox + sending/queued dirs and return counts + age.
|
|
2492
|
+
* Cheap — a handful of readdir + head-read per file. Called by both the
|
|
2493
|
+
* polling UI (status bar) and emitted as an event after queue mutations. */
|
|
2494
|
+
getOutboxStatus() {
|
|
2495
|
+
const configDir = getConfigDir();
|
|
2496
|
+
const perAccount = {};
|
|
2497
|
+
let total = 0;
|
|
2498
|
+
let retrying = 0;
|
|
2499
|
+
let claimed = 0;
|
|
2500
|
+
let oldestMs = 0;
|
|
2501
|
+
let maxAttempts = 0;
|
|
2502
|
+
const now = Date.now();
|
|
2503
|
+
const scan = (accountId, dir) => {
|
|
2504
|
+
if (!fs.existsSync(dir))
|
|
2505
|
+
return;
|
|
2506
|
+
for (const f of fs.readdirSync(dir)) {
|
|
2507
|
+
const isClaim = /\.sending-[^-]+-\d+$/.test(f);
|
|
2508
|
+
const isActive = isClaim || f.endsWith(".ltr") || f.endsWith(".eml");
|
|
2509
|
+
if (!isActive)
|
|
2510
|
+
continue;
|
|
2511
|
+
total++;
|
|
2512
|
+
const acctSlot = perAccount[accountId] ||= { total: 0, retrying: 0, claimed: 0 };
|
|
2513
|
+
acctSlot.total++;
|
|
2514
|
+
if (isClaim) {
|
|
2515
|
+
claimed++;
|
|
2516
|
+
acctSlot.claimed++;
|
|
2517
|
+
}
|
|
2518
|
+
const fp = path.join(dir, f);
|
|
2519
|
+
try {
|
|
2520
|
+
const st = fs.statSync(fp);
|
|
2521
|
+
const age = now - st.mtimeMs;
|
|
2522
|
+
if (age > oldestMs)
|
|
2523
|
+
oldestMs = age;
|
|
2524
|
+
// Only read header region to count retry attempts — tiny I/O.
|
|
2525
|
+
const fd = fs.openSync(fp, "r");
|
|
2526
|
+
try {
|
|
2527
|
+
const buf = Buffer.alloc(4096);
|
|
2528
|
+
const n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
2529
|
+
const head = buf.slice(0, n).toString("utf-8");
|
|
2530
|
+
const info = parseRetryInfo(head);
|
|
2531
|
+
if (info.attemptCount > 0) {
|
|
2532
|
+
retrying++;
|
|
2533
|
+
acctSlot.retrying++;
|
|
2534
|
+
}
|
|
2535
|
+
if (info.attemptCount > maxAttempts)
|
|
2536
|
+
maxAttempts = info.attemptCount;
|
|
2537
|
+
}
|
|
2538
|
+
finally {
|
|
2539
|
+
fs.closeSync(fd);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
catch { /* ignore per-file errors */ }
|
|
2543
|
+
}
|
|
2544
|
+
};
|
|
2545
|
+
const outboxRoot = path.join(configDir, "outbox");
|
|
2546
|
+
const sendingRoot = path.join(configDir, "sending");
|
|
2547
|
+
try {
|
|
2548
|
+
if (fs.existsSync(outboxRoot)) {
|
|
2549
|
+
for (const acct of fs.readdirSync(outboxRoot))
|
|
2550
|
+
scan(acct, path.join(outboxRoot, acct));
|
|
2551
|
+
}
|
|
2552
|
+
if (fs.existsSync(sendingRoot)) {
|
|
2553
|
+
for (const acct of fs.readdirSync(sendingRoot)) {
|
|
2554
|
+
scan(acct, path.join(sendingRoot, acct, "queued"));
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
catch { /* */ }
|
|
2559
|
+
return {
|
|
2560
|
+
total, retrying, claimed,
|
|
2561
|
+
oldestAgeSec: Math.floor(oldestMs / 1000),
|
|
2562
|
+
maxAttempts,
|
|
2563
|
+
perAccount,
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
/** Emit outboxStatus now. Call after any queue mutation. */
|
|
2567
|
+
emitOutboxStatus() {
|
|
2568
|
+
try {
|
|
2569
|
+
this.emit("outboxStatus", this.getOutboxStatus());
|
|
2570
|
+
}
|
|
2571
|
+
catch { /* */ }
|
|
2465
2572
|
}
|
|
2466
2573
|
/** Guard against concurrent processSendActions for the same account */
|
|
2467
2574
|
sendingAccounts = new Set();
|
|
@@ -2980,6 +3087,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2980
3087
|
}
|
|
2981
3088
|
}
|
|
2982
3089
|
}
|
|
3090
|
+
// After each full tick, refresh the UI indicator.
|
|
3091
|
+
this.emitOutboxStatus();
|
|
2983
3092
|
};
|
|
2984
3093
|
setTimeout(() => processAll(), 3000);
|
|
2985
3094
|
this.outboxInterval = setInterval(processAll, 10000);
|
|
@@ -34,6 +34,8 @@ export declare class MailxService {
|
|
|
34
34
|
getSyncPending(): {
|
|
35
35
|
pending: number;
|
|
36
36
|
};
|
|
37
|
+
/** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
|
|
38
|
+
getOutboxStatus(): any;
|
|
37
39
|
syncAll(): Promise<void>;
|
|
38
40
|
syncAccount(accountId: string): Promise<void>;
|
|
39
41
|
/** Force re-authentication for an account (deletes token, opens browser consent) */
|
|
@@ -409,6 +409,10 @@ export class MailxService {
|
|
|
409
409
|
getSyncPending() {
|
|
410
410
|
return { pending: this.db.getTotalPendingSyncCount() };
|
|
411
411
|
}
|
|
412
|
+
/** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
|
|
413
|
+
getOutboxStatus() {
|
|
414
|
+
return this.imapManager.getOutboxStatus();
|
|
415
|
+
}
|
|
412
416
|
async syncAll() {
|
|
413
417
|
await this.imapManager.syncAll();
|
|
414
418
|
}
|
|
@@ -440,6 +444,9 @@ export class MailxService {
|
|
|
440
444
|
// locally. Everything else (contacts recording, IMAP APPEND,
|
|
441
445
|
// SMTP) happens after the IPC ACK. Settings come from cache so
|
|
442
446
|
// a stalled GDrive mount doesn't block the send.
|
|
447
|
+
const t0 = Date.now();
|
|
448
|
+
const lap = (label) => console.log(` [send] +${Date.now() - t0}ms ${label}`);
|
|
449
|
+
console.log(` [send] ENTRY from=${msg?.from} to=${JSON.stringify(msg?.to)} subject="${msg?.subject}" attachments=${msg?.attachments?.length || 0}`);
|
|
443
450
|
const accounts = this.getCachedAccounts();
|
|
444
451
|
let account = accounts.find(a => a.id === msg.from);
|
|
445
452
|
if (!account) {
|
|
@@ -447,8 +454,12 @@ export class MailxService {
|
|
|
447
454
|
this._accountsCache = null;
|
|
448
455
|
account = this.getCachedAccounts().find(a => a.id === msg.from);
|
|
449
456
|
}
|
|
450
|
-
if (!account)
|
|
457
|
+
if (!account) {
|
|
458
|
+
const ids = accounts.map(a => a.id).join(", ");
|
|
459
|
+
console.error(` [send] FAIL: Unknown account "${msg.from}". Known accounts: [${ids}]`);
|
|
451
460
|
throw new Error(`Unknown account: ${msg.from}`);
|
|
461
|
+
}
|
|
462
|
+
lap("account resolved");
|
|
452
463
|
// Vet every recipient address — refuse to send if any field contains a
|
|
453
464
|
// non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
|
|
454
465
|
// autocomplete). This catches garbage BEFORE it hits SMTP, where the
|
|
@@ -531,7 +542,9 @@ export class MailxService {
|
|
|
531
542
|
].join("\r\n");
|
|
532
543
|
rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
|
|
533
544
|
}
|
|
545
|
+
lap(`MIME assembled (${rawMessage.length} bytes${hasAttachments ? `, ${msg.attachments.length} attachment(s)` : ""})`);
|
|
534
546
|
this.imapManager.queueOutgoingLocal(account.id, rawMessage);
|
|
547
|
+
lap("queued to disk");
|
|
535
548
|
console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
|
|
536
549
|
// Contacts recording is off the critical path — deferred until after
|
|
537
550
|
// the IPC ACK so a slow DB write can't stall the send.
|
|
@@ -604,8 +617,13 @@ export class MailxService {
|
|
|
604
617
|
/** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
|
|
605
618
|
* Throws if the account has no spam folder configured or the folder doesn't exist locally. */
|
|
606
619
|
async markAsSpamMessages(accountId, uids) {
|
|
607
|
-
|
|
608
|
-
|
|
620
|
+
// Cached accounts — same reason as send/saveDraft: a stalled GDrive
|
|
621
|
+
// mount could turn `Mark as spam` into a 120s IPC timeout.
|
|
622
|
+
let account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
623
|
+
if (!account) {
|
|
624
|
+
this._accountsCache = null;
|
|
625
|
+
account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
626
|
+
}
|
|
609
627
|
if (!account)
|
|
610
628
|
throw new Error(`Account ${accountId} not found`);
|
|
611
629
|
const spamPath = account.spam;
|
|
@@ -760,8 +778,15 @@ export class MailxService {
|
|
|
760
778
|
// a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
|
|
761
779
|
// so the reconciler can de-duplicate on the server by header search
|
|
762
780
|
// even without the previousDraftUid round-trip.
|
|
763
|
-
|
|
764
|
-
|
|
781
|
+
// Account lookup uses the cached list — `loadSettings()` reads
|
|
782
|
+
// accounts.jsonc from the GDrive mount and could itself stall for
|
|
783
|
+
// 120s, which was the actual `mailxapi timeout: saveDraft` source
|
|
784
|
+
// (the IMAP work was fire-and-forget, but loadSettings wasn't).
|
|
785
|
+
let account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
786
|
+
if (!account) {
|
|
787
|
+
this._accountsCache = null;
|
|
788
|
+
account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
789
|
+
}
|
|
765
790
|
if (!account)
|
|
766
791
|
throw new Error(`Unknown account: ${accountId}`);
|
|
767
792
|
// Generate or reuse a stable draft ID for dedup
|
|
@@ -89,6 +89,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
89
89
|
return { ok: true };
|
|
90
90
|
case "getSyncPending":
|
|
91
91
|
return svc.getSyncPending();
|
|
92
|
+
case "getOutboxStatus":
|
|
93
|
+
return svc.getOutboxStatus();
|
|
92
94
|
case "reauthenticate":
|
|
93
95
|
return { ok: await svc.reauthenticate(p.accountId) };
|
|
94
96
|
// Search & contacts
|