@bobfrankston/mailx 1.0.57 → 1.0.64
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 +52 -0
- package/client/styles/components.css +12 -0
- package/package.json +2 -2
- package/packages/mailx-api/index.js +9 -0
- package/packages/mailx-imap/index.d.ts +3 -0
- package/packages/mailx-imap/index.js +115 -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,53 @@ onWsEvent((event) => {
|
|
|
561
561
|
statusSync.textContent = `Error: ${event.message}`;
|
|
562
562
|
showAlert(event.message, "ws-error");
|
|
563
563
|
break;
|
|
564
|
+
case "accountError": {
|
|
565
|
+
// Show in alert banner with re-auth button
|
|
566
|
+
const msg = `${event.accountId}: ${event.hint}`;
|
|
567
|
+
showAlert(msg, `acct-${event.accountId}`);
|
|
568
|
+
// Add re-auth button to the banner
|
|
569
|
+
const bannerText = document.getElementById("alert-text");
|
|
570
|
+
if (bannerText && bannerText.textContent === msg) {
|
|
571
|
+
const existing = bannerText.parentElement?.querySelector(".status-action");
|
|
572
|
+
if (!existing) {
|
|
573
|
+
const btn = document.createElement("button");
|
|
574
|
+
btn.textContent = "Re-authenticate";
|
|
575
|
+
btn.className = "status-action";
|
|
576
|
+
btn.addEventListener("click", async () => {
|
|
577
|
+
btn.disabled = true;
|
|
578
|
+
btn.textContent = "Authenticating...";
|
|
579
|
+
try {
|
|
580
|
+
const res = await fetch(`/api/reauth/${event.accountId}`, { method: "POST" });
|
|
581
|
+
const data = await res.json();
|
|
582
|
+
if (data.ok) {
|
|
583
|
+
hideAlert();
|
|
584
|
+
const acctEl = document.getElementById("status-accounts");
|
|
585
|
+
if (acctEl) {
|
|
586
|
+
acctEl.textContent = `${event.accountId}: reconnected`;
|
|
587
|
+
acctEl.style.color = "";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
btn.textContent = "Re-authenticate";
|
|
592
|
+
btn.disabled = false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
btn.textContent = "Re-authenticate";
|
|
597
|
+
btn.disabled = false;
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Also show in status bar
|
|
604
|
+
const acctEl = document.getElementById("status-accounts");
|
|
605
|
+
if (acctEl) {
|
|
606
|
+
acctEl.textContent = `${event.accountId}: ${event.hint}`;
|
|
607
|
+
acctEl.style.color = "oklch(0.65 0.2 25)";
|
|
608
|
+
}
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
564
611
|
}
|
|
565
612
|
});
|
|
566
613
|
// ── Keyboard shortcuts ──
|
|
@@ -570,6 +617,11 @@ document.addEventListener("keydown", (e) => {
|
|
|
570
617
|
e.preventDefault();
|
|
571
618
|
openCompose("new");
|
|
572
619
|
}
|
|
620
|
+
// Ctrl+F = Forward
|
|
621
|
+
if (e.ctrlKey && e.key === "f") {
|
|
622
|
+
e.preventDefault();
|
|
623
|
+
openCompose("forward");
|
|
624
|
+
}
|
|
573
625
|
// Ctrl+R = Reply
|
|
574
626
|
if (e.ctrlKey && e.key === "r" && !e.shiftKey) {
|
|
575
627
|
e.preventDefault();
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.64",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@bobfrankston/iflow": "^1.0.30",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
25
|
+
"@bobfrankston/oauthsupport": "^1.0.14",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.2",
|
|
27
27
|
"mailparser": "^3.7.2",
|
|
28
28
|
"quill": "^2.0.3",
|
|
@@ -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 IMAP 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,38 @@ 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 IMAP 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 only the IMAP token (not contacts — separate scope, separate consent)
|
|
82
|
+
const accountDir = account.imap.user.replace(/[@.]/g, "_");
|
|
83
|
+
const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
|
|
84
|
+
const tokenPath = path.join(tokenDir, "token.json");
|
|
85
|
+
if (fs.existsSync(tokenPath)) {
|
|
86
|
+
fs.unlinkSync(tokenPath);
|
|
87
|
+
console.log(` [reauth] Deleted ${tokenPath}`);
|
|
88
|
+
}
|
|
89
|
+
// Re-register the account to get a fresh config with new tokenProvider
|
|
90
|
+
this.configs.delete(accountId);
|
|
91
|
+
await this.addAccount(account);
|
|
92
|
+
// Trigger the OAuth flow by requesting a token
|
|
93
|
+
try {
|
|
94
|
+
const token = await this.getOAuthToken(accountId);
|
|
95
|
+
if (token) {
|
|
96
|
+
console.log(` [reauth] ${accountId}: success`);
|
|
97
|
+
// Trigger a sync now that auth works
|
|
98
|
+
this.syncInbox().catch(() => { });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
console.error(` [reauth] ${accountId}: ${e.message}`);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
75
107
|
/** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
|
|
76
108
|
async deleteOnServer(accountId, folderPath, uid) {
|
|
77
109
|
const client = this.createClient(accountId);
|
|
@@ -126,6 +158,17 @@ export class ImapManager extends EventEmitter {
|
|
|
126
158
|
this.configs.set(account.id, config);
|
|
127
159
|
// Register account in DB
|
|
128
160
|
this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
161
|
+
// Pre-validate OAuth token (so browser consent happens now, not during a timed sync)
|
|
162
|
+
if (config.tokenProvider) {
|
|
163
|
+
try {
|
|
164
|
+
await config.tokenProvider();
|
|
165
|
+
console.log(` [auth] ${account.id}: token valid`);
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
console.error(` [auth] ${account.id}: ${e.message}`);
|
|
169
|
+
this.emit("accountError", account.id, e.message, "Re-authenticate: click the button below or run mailx -setup");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
129
172
|
}
|
|
130
173
|
/** Sync folder list for an account */
|
|
131
174
|
async syncFolders(accountId, client) {
|
|
@@ -278,24 +321,35 @@ export class ImapManager extends EventEmitter {
|
|
|
278
321
|
}
|
|
279
322
|
if (newCount > 0)
|
|
280
323
|
console.log(` stored ${newCount} new messages`);
|
|
281
|
-
// Remove messages deleted on the server
|
|
324
|
+
// Remove messages deleted on the server (skip on first sync — nothing to reconcile)
|
|
282
325
|
let deletedCount = 0;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
326
|
+
if (!firstSync) {
|
|
327
|
+
let delClient = null;
|
|
328
|
+
try {
|
|
329
|
+
delClient = this.createClient(accountId);
|
|
330
|
+
const serverUids = new Set(await delClient.getUids(folder.path));
|
|
331
|
+
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
332
|
+
for (const uid of localUids) {
|
|
333
|
+
if (!serverUids.has(uid)) {
|
|
334
|
+
this.db.deleteMessage(accountId, uid);
|
|
335
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
336
|
+
deletedCount++;
|
|
337
|
+
}
|
|
292
338
|
}
|
|
339
|
+
if (deletedCount > 0)
|
|
340
|
+
console.log(` removed ${deletedCount} deleted messages`);
|
|
341
|
+
await delClient.logout();
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
console.error(` deletion sync error: ${e.message}`);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
if (delClient)
|
|
348
|
+
try {
|
|
349
|
+
await delClient.logout();
|
|
350
|
+
}
|
|
351
|
+
catch { /* ignore */ }
|
|
293
352
|
}
|
|
294
|
-
if (deletedCount > 0)
|
|
295
|
-
console.log(` removed ${deletedCount} deleted messages`);
|
|
296
|
-
}
|
|
297
|
-
catch (e) {
|
|
298
|
-
console.error(` deletion sync error: ${e.message}`);
|
|
299
353
|
}
|
|
300
354
|
// Update folder counts from local DB (after deletions + additions)
|
|
301
355
|
const result = this.db.getMessages({ accountId, folderId, page: 1, pageSize: 1 });
|
|
@@ -367,6 +421,11 @@ export class ImapManager extends EventEmitter {
|
|
|
367
421
|
catch (e) {
|
|
368
422
|
this.emit("syncError", accountId, e.message);
|
|
369
423
|
console.error(`Sync error for ${accountId}: ${e.message}`);
|
|
424
|
+
// Emit user-facing error — always offer re-auth for OAuth accounts
|
|
425
|
+
const config = this.configs.get(accountId);
|
|
426
|
+
const isOAuth = !!config?.tokenProvider;
|
|
427
|
+
const hint = isOAuth ? "Authentication may have expired" : "Check server connectivity";
|
|
428
|
+
this.emit("accountError", accountId, e.message || "unknown", hint);
|
|
370
429
|
}
|
|
371
430
|
finally {
|
|
372
431
|
if (client)
|
|
@@ -376,17 +435,32 @@ export class ImapManager extends EventEmitter {
|
|
|
376
435
|
catch { /* ignore */ }
|
|
377
436
|
}
|
|
378
437
|
}
|
|
379
|
-
// Phase 2: Sync remaining folders
|
|
438
|
+
// Phase 2: Sync remaining folders — priority order, skip Trash subfolders on first sync
|
|
439
|
+
const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
|
|
380
440
|
for (const [accountId, folders] of accountFolders) {
|
|
441
|
+
// Sort: sent/drafts first, then regular, then trash subfolders last
|
|
442
|
+
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
443
|
+
remaining.sort((a, b) => {
|
|
444
|
+
const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
|
|
445
|
+
const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
|
|
446
|
+
return pa - pb;
|
|
447
|
+
});
|
|
381
448
|
let client = null;
|
|
382
|
-
for (const folder of
|
|
383
|
-
|
|
384
|
-
|
|
449
|
+
for (const folder of remaining) {
|
|
450
|
+
// Skip Trash subfolders on first sync — they're large and low priority
|
|
451
|
+
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
452
|
+
const highestUid = this.db.getHighestUid(accountId, folder.id);
|
|
453
|
+
if (isTrashChild && highestUid === 0) {
|
|
454
|
+
console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// Longer timeout for folders we know are large (Trash, first sync)
|
|
458
|
+
const timeout = highestUid === 0 ? 180000 : 60000;
|
|
385
459
|
try {
|
|
386
460
|
client = this.createClient(accountId);
|
|
387
461
|
await Promise.race([
|
|
388
462
|
this.syncFolder(accountId, folder.id, client),
|
|
389
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(
|
|
463
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s)`)), timeout))
|
|
390
464
|
]);
|
|
391
465
|
await client.logout();
|
|
392
466
|
client = null;
|
|
@@ -1054,35 +1128,33 @@ export class ImapManager extends EventEmitter {
|
|
|
1054
1128
|
}
|
|
1055
1129
|
}
|
|
1056
1130
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
1131
|
+
outboxBackoff = new Map(); // accountId → next retry time
|
|
1057
1132
|
startOutboxWorker() {
|
|
1058
1133
|
if (this.outboxInterval)
|
|
1059
1134
|
return;
|
|
1060
|
-
// Run once immediately on startup
|
|
1061
1135
|
const processAll = async () => {
|
|
1136
|
+
const now = Date.now();
|
|
1062
1137
|
for (const [accountId] of this.configs) {
|
|
1138
|
+
// Skip accounts in backoff
|
|
1139
|
+
const retryAfter = this.outboxBackoff.get(accountId) || 0;
|
|
1140
|
+
if (now < retryAfter)
|
|
1141
|
+
continue;
|
|
1063
1142
|
try {
|
|
1064
1143
|
await this.processLocalQueue(accountId);
|
|
1065
1144
|
await this.processOutbox(accountId);
|
|
1145
|
+
this.outboxBackoff.delete(accountId); // success — clear backoff
|
|
1066
1146
|
}
|
|
1067
1147
|
catch (e) {
|
|
1068
|
-
|
|
1148
|
+
// Exponential backoff: 30s, 60s, 120s, max 5min
|
|
1149
|
+
const prev = this.outboxBackoff.get(accountId);
|
|
1150
|
+
const delay = prev ? Math.min((now - prev + 30000) * 2, 300000) : 30000;
|
|
1151
|
+
this.outboxBackoff.set(accountId, now + delay);
|
|
1152
|
+
console.error(` [outbox] Error for ${accountId}: ${e.message} (retry in ${Math.round(delay / 1000)}s)`);
|
|
1069
1153
|
}
|
|
1070
1154
|
}
|
|
1071
1155
|
};
|
|
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);
|
|
1156
|
+
setTimeout(() => processAll(), 3000);
|
|
1157
|
+
this.outboxInterval = setInterval(processAll, 10000);
|
|
1086
1158
|
}
|
|
1087
1159
|
/** Stop Outbox worker */
|
|
1088
1160
|
stopOutboxWorker() {
|
|
@@ -1111,6 +1183,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1111
1183
|
}
|
|
1112
1184
|
const accountDir = account.imap.user.replace(/[@.]/g, "_");
|
|
1113
1185
|
const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
|
|
1186
|
+
const tokenPath = path.join(tokenDir, "contacts-token.json");
|
|
1187
|
+
// If no cached token exists, skip — don't open interactive browser consent for contacts
|
|
1188
|
+
// The user can trigger contacts sync manually or it will be set up during mailx -setup
|
|
1189
|
+
if (!fs.existsSync(tokenPath)) {
|
|
1190
|
+
console.log(` [contacts] No token for ${accountId} — skipping (run mailx -setup to authorize)`);
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1114
1193
|
const token = await authenticateOAuth(credentialsPath, {
|
|
1115
1194
|
scope: "https://www.googleapis.com/auth/contacts.readonly",
|
|
1116
1195
|
tokenDirectory: tokenDir,
|
|
@@ -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();
|