@bobfrankston/mailx 1.0.57 → 1.0.61
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/client/app.js +38 -0
- package/client/styles/components.css +12 -0
- package/package.json +1 -1
- package/packages/mailx-api/index.js +9 -0
- package/packages/mailx-imap/index.d.ts +3 -0
- package/packages/mailx-imap/index.js +113 -36
- package/packages/mailx-server/index.js +3 -0
- package/packages/mailx-service/index.d.ts +2 -0
- package/packages/mailx-service/index.js +4 -0
- package/packages/mailx-types/index.d.ts +5 -0
package/client/app.js
CHANGED
|
@@ -561,6 +561,44 @@ onWsEvent((event) => {
|
|
|
561
561
|
statusSync.textContent = `Error: ${event.message}`;
|
|
562
562
|
showAlert(event.message, "ws-error");
|
|
563
563
|
break;
|
|
564
|
+
case "accountError": {
|
|
565
|
+
const acctEl = document.getElementById("status-accounts");
|
|
566
|
+
if (acctEl) {
|
|
567
|
+
acctEl.innerHTML = "";
|
|
568
|
+
acctEl.style.color = "oklch(0.65 0.2 25)";
|
|
569
|
+
const text = document.createElement("span");
|
|
570
|
+
text.textContent = `${event.accountId}: ${event.hint} `;
|
|
571
|
+
acctEl.appendChild(text);
|
|
572
|
+
// Add re-auth button for auth errors
|
|
573
|
+
if (event.error.includes("token") || event.error.includes("auth") || event.error.includes("Command failed")) {
|
|
574
|
+
const btn = document.createElement("button");
|
|
575
|
+
btn.textContent = "Re-authenticate";
|
|
576
|
+
btn.className = "status-action";
|
|
577
|
+
btn.addEventListener("click", async () => {
|
|
578
|
+
btn.disabled = true;
|
|
579
|
+
btn.textContent = "Authenticating...";
|
|
580
|
+
try {
|
|
581
|
+
const res = await fetch(`/api/reauth/${event.accountId}`, { method: "POST" });
|
|
582
|
+
const data = await res.json();
|
|
583
|
+
if (data.ok) {
|
|
584
|
+
acctEl.textContent = `${event.accountId}: reconnected`;
|
|
585
|
+
acctEl.style.color = "";
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
btn.textContent = "Re-authenticate";
|
|
589
|
+
btn.disabled = false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
btn.textContent = "Re-authenticate";
|
|
594
|
+
btn.disabled = false;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
acctEl.appendChild(btn);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
564
602
|
}
|
|
565
603
|
});
|
|
566
604
|
// ── Keyboard shortcuts ──
|
|
@@ -564,6 +564,18 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
564
564
|
font-size: var(--font-size-sm);
|
|
565
565
|
color: var(--color-text-muted);
|
|
566
566
|
}
|
|
567
|
+
.status-action {
|
|
568
|
+
border: 1px solid oklch(0.65 0.15 25);
|
|
569
|
+
background: transparent;
|
|
570
|
+
color: oklch(0.75 0.15 25);
|
|
571
|
+
padding: 1px 8px;
|
|
572
|
+
border-radius: var(--radius-sm);
|
|
573
|
+
font-size: var(--font-size-sm);
|
|
574
|
+
cursor: pointer;
|
|
575
|
+
margin-left: var(--gap-xs);
|
|
576
|
+
}
|
|
577
|
+
.status-action:hover { background: oklch(0.65 0.15 25); color: #fff; }
|
|
578
|
+
.status-action:disabled { opacity: 0.5; cursor: default; }
|
|
567
579
|
|
|
568
580
|
/* ── Startup Overlay ── */
|
|
569
581
|
|
package/package.json
CHANGED
|
@@ -75,6 +75,15 @@ export function createApiRouter(db, imapManager) {
|
|
|
75
75
|
res.status(500).json({ error: e.message });
|
|
76
76
|
}
|
|
77
77
|
});
|
|
78
|
+
router.post("/reauth/:accountId", async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const ok = await svc.reauthenticate(req.params.accountId);
|
|
81
|
+
res.json({ ok });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
res.status(500).json({ error: e.message });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
78
87
|
// ── Send ──
|
|
79
88
|
router.post("/send", async (req, res) => {
|
|
80
89
|
try {
|
|
@@ -30,6 +30,8 @@ export declare class ImapManager extends EventEmitter {
|
|
|
30
30
|
constructor(db: MailxDB);
|
|
31
31
|
/** Get OAuth access token for an account (for SMTP auth) */
|
|
32
32
|
getOAuthToken(accountId: string): Promise<string | null>;
|
|
33
|
+
/** Force re-authentication for an OAuth account — deletes cached token, triggers browser consent */
|
|
34
|
+
reauthenticate(accountId: string): Promise<boolean>;
|
|
33
35
|
/** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
|
|
34
36
|
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
35
37
|
/** Search messages on the IMAP server — returns matching UIDs */
|
|
@@ -103,6 +105,7 @@ export declare class ImapManager extends EventEmitter {
|
|
|
103
105
|
/** Process Outbox — send pending messages with flag-based interlock */
|
|
104
106
|
processOutbox(accountId: string): Promise<void>;
|
|
105
107
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
108
|
+
private outboxBackoff;
|
|
106
109
|
startOutboxWorker(): void;
|
|
107
110
|
/** Stop Outbox worker */
|
|
108
111
|
stopOutboxWorker(): void;
|
|
@@ -72,6 +72,40 @@ export class ImapManager extends EventEmitter {
|
|
|
72
72
|
return null;
|
|
73
73
|
return config.tokenProvider();
|
|
74
74
|
}
|
|
75
|
+
/** Force re-authentication for an OAuth account — deletes cached token, triggers browser consent */
|
|
76
|
+
async reauthenticate(accountId) {
|
|
77
|
+
const settings = loadSettings();
|
|
78
|
+
const account = settings.accounts.find(a => a.id === accountId);
|
|
79
|
+
if (!account)
|
|
80
|
+
return false;
|
|
81
|
+
// Delete cached tokens
|
|
82
|
+
const accountDir = account.imap.user.replace(/[@.]/g, "_");
|
|
83
|
+
const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
|
|
84
|
+
for (const f of ["token.json", "contacts-token.json"]) {
|
|
85
|
+
const p = path.join(tokenDir, f);
|
|
86
|
+
if (fs.existsSync(p)) {
|
|
87
|
+
fs.unlinkSync(p);
|
|
88
|
+
console.log(` [reauth] Deleted ${p}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Re-register the account to get a fresh config with new tokenProvider
|
|
92
|
+
this.configs.delete(accountId);
|
|
93
|
+
await this.addAccount(account);
|
|
94
|
+
// Trigger the OAuth flow by requesting a token
|
|
95
|
+
try {
|
|
96
|
+
const token = await this.getOAuthToken(accountId);
|
|
97
|
+
if (token) {
|
|
98
|
+
console.log(` [reauth] ${accountId}: success`);
|
|
99
|
+
// Trigger a sync now that auth works
|
|
100
|
+
this.syncInbox().catch(() => { });
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
console.error(` [reauth] ${accountId}: ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
75
109
|
/** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
|
|
76
110
|
async deleteOnServer(accountId, folderPath, uid) {
|
|
77
111
|
const client = this.createClient(accountId);
|
|
@@ -126,6 +160,17 @@ export class ImapManager extends EventEmitter {
|
|
|
126
160
|
this.configs.set(account.id, config);
|
|
127
161
|
// Register account in DB
|
|
128
162
|
this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
163
|
+
// Pre-validate OAuth token (so browser consent happens now, not during a timed sync)
|
|
164
|
+
if (config.tokenProvider) {
|
|
165
|
+
try {
|
|
166
|
+
await config.tokenProvider();
|
|
167
|
+
console.log(` [auth] ${account.id}: token valid`);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
console.error(` [auth] ${account.id}: ${e.message}`);
|
|
171
|
+
this.emit("accountError", account.id, e.message, "Re-authenticate: click the button below or run mailx -setup");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
129
174
|
}
|
|
130
175
|
/** Sync folder list for an account */
|
|
131
176
|
async syncFolders(accountId, client) {
|
|
@@ -278,24 +323,35 @@ export class ImapManager extends EventEmitter {
|
|
|
278
323
|
}
|
|
279
324
|
if (newCount > 0)
|
|
280
325
|
console.log(` stored ${newCount} new messages`);
|
|
281
|
-
// Remove messages deleted on the server
|
|
326
|
+
// Remove messages deleted on the server (skip on first sync — nothing to reconcile)
|
|
282
327
|
let deletedCount = 0;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
328
|
+
if (!firstSync) {
|
|
329
|
+
let delClient = null;
|
|
330
|
+
try {
|
|
331
|
+
delClient = this.createClient(accountId);
|
|
332
|
+
const serverUids = new Set(await delClient.getUids(folder.path));
|
|
333
|
+
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
334
|
+
for (const uid of localUids) {
|
|
335
|
+
if (!serverUids.has(uid)) {
|
|
336
|
+
this.db.deleteMessage(accountId, uid);
|
|
337
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
338
|
+
deletedCount++;
|
|
339
|
+
}
|
|
292
340
|
}
|
|
341
|
+
if (deletedCount > 0)
|
|
342
|
+
console.log(` removed ${deletedCount} deleted messages`);
|
|
343
|
+
await delClient.logout();
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
console.error(` deletion sync error: ${e.message}`);
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
if (delClient)
|
|
350
|
+
try {
|
|
351
|
+
await delClient.logout();
|
|
352
|
+
}
|
|
353
|
+
catch { /* ignore */ }
|
|
293
354
|
}
|
|
294
|
-
if (deletedCount > 0)
|
|
295
|
-
console.log(` removed ${deletedCount} deleted messages`);
|
|
296
|
-
}
|
|
297
|
-
catch (e) {
|
|
298
|
-
console.error(` deletion sync error: ${e.message}`);
|
|
299
355
|
}
|
|
300
356
|
// Update folder counts from local DB (after deletions + additions)
|
|
301
357
|
const result = this.db.getMessages({ accountId, folderId, page: 1, pageSize: 1 });
|
|
@@ -367,6 +423,14 @@ export class ImapManager extends EventEmitter {
|
|
|
367
423
|
catch (e) {
|
|
368
424
|
this.emit("syncError", accountId, e.message);
|
|
369
425
|
console.error(`Sync error for ${accountId}: ${e.message}`);
|
|
426
|
+
// Classify error and emit user-facing hint
|
|
427
|
+
const msg = e.message || "";
|
|
428
|
+
if (msg.includes("token") || msg.includes("auth") || msg.includes("Command failed")) {
|
|
429
|
+
this.emit("accountError", accountId, msg, "Re-authenticate: mailx -setup or check credentials");
|
|
430
|
+
}
|
|
431
|
+
else if (msg.includes("timeout")) {
|
|
432
|
+
this.emit("accountError", accountId, msg, "Server slow or unreachable — will retry");
|
|
433
|
+
}
|
|
370
434
|
}
|
|
371
435
|
finally {
|
|
372
436
|
if (client)
|
|
@@ -376,17 +440,32 @@ export class ImapManager extends EventEmitter {
|
|
|
376
440
|
catch { /* ignore */ }
|
|
377
441
|
}
|
|
378
442
|
}
|
|
379
|
-
// Phase 2: Sync remaining folders
|
|
443
|
+
// Phase 2: Sync remaining folders — priority order, skip Trash subfolders on first sync
|
|
444
|
+
const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
|
|
380
445
|
for (const [accountId, folders] of accountFolders) {
|
|
446
|
+
// Sort: sent/drafts first, then regular, then trash subfolders last
|
|
447
|
+
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
448
|
+
remaining.sort((a, b) => {
|
|
449
|
+
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
450
|
+
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
451
|
+
return pa - pb;
|
|
452
|
+
});
|
|
381
453
|
let client = null;
|
|
382
|
-
for (const folder of
|
|
383
|
-
|
|
384
|
-
|
|
454
|
+
for (const folder of remaining) {
|
|
455
|
+
// Skip Trash subfolders on first sync — they're large and low priority
|
|
456
|
+
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
457
|
+
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
458
|
+
if (isTrashChild && highestUid === 0) {
|
|
459
|
+
console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
// Longer timeout for folders we know are large (Trash, first sync)
|
|
463
|
+
const timeout = highestUid === 0 ? 180000 : 60000;
|
|
385
464
|
try {
|
|
386
465
|
client = this.createClient(accountId);
|
|
387
466
|
await Promise.race([
|
|
388
467
|
this.syncFolder(accountId, folder.id, client),
|
|
389
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(
|
|
468
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s)`)), timeout))
|
|
390
469
|
]);
|
|
391
470
|
await client.logout();
|
|
392
471
|
client = null;
|
|
@@ -1054,35 +1133,33 @@ export class ImapManager extends EventEmitter {
|
|
|
1054
1133
|
}
|
|
1055
1134
|
}
|
|
1056
1135
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
1136
|
+
outboxBackoff = new Map(); // accountId → next retry time
|
|
1057
1137
|
startOutboxWorker() {
|
|
1058
1138
|
if (this.outboxInterval)
|
|
1059
1139
|
return;
|
|
1060
|
-
// Run once immediately on startup
|
|
1061
1140
|
const processAll = async () => {
|
|
1141
|
+
const now = Date.now();
|
|
1062
1142
|
for (const [accountId] of this.configs) {
|
|
1143
|
+
// Skip accounts in backoff
|
|
1144
|
+
const retryAfter = this.outboxBackoff.get(accountId) || 0;
|
|
1145
|
+
if (now < retryAfter)
|
|
1146
|
+
continue;
|
|
1063
1147
|
try {
|
|
1064
1148
|
await this.processLocalQueue(accountId);
|
|
1065
1149
|
await this.processOutbox(accountId);
|
|
1150
|
+
this.outboxBackoff.delete(accountId); // success — clear backoff
|
|
1066
1151
|
}
|
|
1067
1152
|
catch (e) {
|
|
1068
|
-
|
|
1153
|
+
// Exponential backoff: 30s, 60s, 120s, max 5min
|
|
1154
|
+
const prev = this.outboxBackoff.get(accountId);
|
|
1155
|
+
const delay = prev ? Math.min((now - prev + 30000) * 2, 300000) : 30000;
|
|
1156
|
+
this.outboxBackoff.set(accountId, now + delay);
|
|
1157
|
+
console.error(` [outbox] Error for ${accountId}: ${e.message} (retry in ${Math.round(delay / 1000)}s)`);
|
|
1069
1158
|
}
|
|
1070
1159
|
}
|
|
1071
1160
|
};
|
|
1072
|
-
setTimeout(() => processAll(), 3000);
|
|
1073
|
-
this.outboxInterval = setInterval(
|
|
1074
|
-
for (const [accountId] of this.configs) {
|
|
1075
|
-
try {
|
|
1076
|
-
// First move any local queued messages to IMAP
|
|
1077
|
-
await this.processLocalQueue(accountId);
|
|
1078
|
-
// Then process IMAP Outbox
|
|
1079
|
-
await this.processOutbox(accountId);
|
|
1080
|
-
}
|
|
1081
|
-
catch (e) {
|
|
1082
|
-
console.error(` [outbox] Error for ${accountId}: ${e.message}`);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
}, 10000);
|
|
1161
|
+
setTimeout(() => processAll(), 3000);
|
|
1162
|
+
this.outboxInterval = setInterval(processAll, 10000);
|
|
1086
1163
|
}
|
|
1087
1164
|
/** Stop Outbox worker */
|
|
1088
1165
|
stopOutboxWorker() {
|
|
@@ -199,6 +199,9 @@ imapManager.on("folderCountsChanged", (accountId, counts) => {
|
|
|
199
199
|
imapManager.on("syncError", (accountId, error) => {
|
|
200
200
|
broadcast({ type: "error", message: `${accountId}: ${error}` });
|
|
201
201
|
});
|
|
202
|
+
imapManager.on("accountError", (accountId, error, hint) => {
|
|
203
|
+
broadcast({ type: "accountError", accountId, error, hint });
|
|
204
|
+
});
|
|
202
205
|
// ── Startup ──
|
|
203
206
|
async function start() {
|
|
204
207
|
console.log("mailx server starting...");
|
|
@@ -28,6 +28,8 @@ export declare class MailxService {
|
|
|
28
28
|
};
|
|
29
29
|
syncAll(): Promise<void>;
|
|
30
30
|
syncAccount(accountId: string): Promise<void>;
|
|
31
|
+
/** Force re-authentication for an account (deletes token, opens browser consent) */
|
|
32
|
+
reauthenticate(accountId: string): Promise<boolean>;
|
|
31
33
|
send(msg: any): Promise<void>;
|
|
32
34
|
deleteMessage(accountId: string, uid: number): Promise<void>;
|
|
33
35
|
moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
|
|
@@ -257,6 +257,10 @@ export class MailxService {
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
|
+
/** Force re-authentication for an account (deletes token, opens browser consent) */
|
|
261
|
+
async reauthenticate(accountId) {
|
|
262
|
+
return this.imapManager.reauthenticate(accountId);
|
|
263
|
+
}
|
|
260
264
|
// ── Send ──
|
|
261
265
|
async send(msg) {
|
|
262
266
|
const settings = loadSettings();
|