@bobfrankston/mailx 1.0.61 → 1.0.66
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 +26 -12
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +3 -1
- package/packages/mailx-imap/index.js +52 -30
package/client/app.js
CHANGED
|
@@ -562,15 +562,14 @@ onWsEvent((event) => {
|
|
|
562
562
|
showAlert(event.message, "ws-error");
|
|
563
563
|
break;
|
|
564
564
|
case "accountError": {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (event.error.includes("token") || event.error.includes("auth") || event.error.includes("Command failed")) {
|
|
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) {
|
|
574
573
|
const btn = document.createElement("button");
|
|
575
574
|
btn.textContent = "Re-authenticate";
|
|
576
575
|
btn.className = "status-action";
|
|
@@ -581,8 +580,12 @@ onWsEvent((event) => {
|
|
|
581
580
|
const res = await fetch(`/api/reauth/${event.accountId}`, { method: "POST" });
|
|
582
581
|
const data = await res.json();
|
|
583
582
|
if (data.ok) {
|
|
584
|
-
|
|
585
|
-
acctEl
|
|
583
|
+
hideAlert();
|
|
584
|
+
const acctEl = document.getElementById("status-accounts");
|
|
585
|
+
if (acctEl) {
|
|
586
|
+
acctEl.textContent = `${event.accountId}: reconnected`;
|
|
587
|
+
acctEl.style.color = "";
|
|
588
|
+
}
|
|
586
589
|
}
|
|
587
590
|
else {
|
|
588
591
|
btn.textContent = "Re-authenticate";
|
|
@@ -594,9 +597,15 @@ onWsEvent((event) => {
|
|
|
594
597
|
btn.disabled = false;
|
|
595
598
|
}
|
|
596
599
|
});
|
|
597
|
-
|
|
600
|
+
bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
|
|
598
601
|
}
|
|
599
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
|
+
}
|
|
600
609
|
break;
|
|
601
610
|
}
|
|
602
611
|
}
|
|
@@ -608,6 +617,11 @@ document.addEventListener("keydown", (e) => {
|
|
|
608
617
|
e.preventDefault();
|
|
609
618
|
openCompose("new");
|
|
610
619
|
}
|
|
620
|
+
// Ctrl+F = Forward
|
|
621
|
+
if (e.ctrlKey && e.key === "f") {
|
|
622
|
+
e.preventDefault();
|
|
623
|
+
openCompose("forward");
|
|
624
|
+
}
|
|
611
625
|
// Ctrl+R = Reply
|
|
612
626
|
if (e.ctrlKey && e.key === "r" && !e.shiftKey) {
|
|
613
627
|
e.preventDefault();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.66",
|
|
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,9 +20,9 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.31",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
|
-
"@bobfrankston/oauthsupport": "^1.0.
|
|
25
|
+
"@bobfrankston/oauthsupport": "^1.0.16",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.2",
|
|
27
27
|
"mailparser": "^3.7.2",
|
|
28
28
|
"quill": "^2.0.3",
|
|
@@ -30,7 +30,9 @@ 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
|
-
/**
|
|
33
|
+
/** Accounts currently re-authenticating — all operations skip these */
|
|
34
|
+
private reauthenticating;
|
|
35
|
+
/** Force re-authentication for an OAuth account — deletes cached IMAP token, triggers browser consent */
|
|
34
36
|
reauthenticate(accountId: string): Promise<boolean>;
|
|
35
37
|
/** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
|
|
36
38
|
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
@@ -72,31 +72,42 @@ export class ImapManager extends EventEmitter {
|
|
|
72
72
|
return null;
|
|
73
73
|
return config.tokenProvider();
|
|
74
74
|
}
|
|
75
|
-
/**
|
|
75
|
+
/** Accounts currently re-authenticating — all operations skip these */
|
|
76
|
+
reauthenticating = new Set();
|
|
77
|
+
/** Force re-authentication for an OAuth account — deletes cached IMAP token, triggers browser consent */
|
|
76
78
|
async reauthenticate(accountId) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
79
|
+
if (this.reauthenticating.has(accountId))
|
|
80
|
+
return false; // already in progress
|
|
81
|
+
this.reauthenticating.add(accountId);
|
|
95
82
|
try {
|
|
96
|
-
const
|
|
97
|
-
|
|
83
|
+
const settings = loadSettings();
|
|
84
|
+
const account = settings.accounts.find(a => a.id === accountId);
|
|
85
|
+
if (!account)
|
|
86
|
+
return false;
|
|
87
|
+
// Stop IDLE watcher for this account
|
|
88
|
+
const stopWatcher = this.watchers.get(accountId);
|
|
89
|
+
if (stopWatcher) {
|
|
90
|
+
try {
|
|
91
|
+
await stopWatcher();
|
|
92
|
+
}
|
|
93
|
+
catch { /* */ }
|
|
94
|
+
this.watchers.delete(accountId);
|
|
95
|
+
}
|
|
96
|
+
// Delete only the IMAP token (not contacts — separate scope, separate consent)
|
|
97
|
+
const accountDir = account.imap.user.replace(/[@.]/g, "_");
|
|
98
|
+
const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
|
|
99
|
+
const tokenPath = path.join(tokenDir, "token.json");
|
|
100
|
+
if (fs.existsSync(tokenPath)) {
|
|
101
|
+
fs.unlinkSync(tokenPath);
|
|
102
|
+
console.log(` [reauth] Deleted ${tokenPath}`);
|
|
103
|
+
}
|
|
104
|
+
// Re-register the account to get a fresh config with new tokenProvider
|
|
105
|
+
this.configs.delete(accountId);
|
|
106
|
+
await this.addAccount(account);
|
|
107
|
+
// addAccount already pre-validates the token (opens browser if needed)
|
|
108
|
+
const config = this.configs.get(accountId);
|
|
109
|
+
if (config?.tokenProvider) {
|
|
98
110
|
console.log(` [reauth] ${accountId}: success`);
|
|
99
|
-
// Trigger a sync now that auth works
|
|
100
111
|
this.syncInbox().catch(() => { });
|
|
101
112
|
return true;
|
|
102
113
|
}
|
|
@@ -104,6 +115,9 @@ export class ImapManager extends EventEmitter {
|
|
|
104
115
|
catch (e) {
|
|
105
116
|
console.error(` [reauth] ${accountId}: ${e.message}`);
|
|
106
117
|
}
|
|
118
|
+
finally {
|
|
119
|
+
this.reauthenticating.delete(accountId);
|
|
120
|
+
}
|
|
107
121
|
return false;
|
|
108
122
|
}
|
|
109
123
|
/** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
|
|
@@ -139,6 +153,8 @@ export class ImapManager extends EventEmitter {
|
|
|
139
153
|
}
|
|
140
154
|
/** Create a fresh ImapClient for an account (disposable, single-use) */
|
|
141
155
|
createClient(accountId) {
|
|
156
|
+
if (this.reauthenticating.has(accountId))
|
|
157
|
+
throw new Error(`Account ${accountId} is re-authenticating`);
|
|
142
158
|
const config = this.configs.get(accountId);
|
|
143
159
|
if (!config)
|
|
144
160
|
throw new Error(`No config for account ${accountId}`);
|
|
@@ -423,14 +439,11 @@ export class ImapManager extends EventEmitter {
|
|
|
423
439
|
catch (e) {
|
|
424
440
|
this.emit("syncError", accountId, e.message);
|
|
425
441
|
console.error(`Sync error for ${accountId}: ${e.message}`);
|
|
426
|
-
//
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
else if (msg.includes("timeout")) {
|
|
432
|
-
this.emit("accountError", accountId, msg, "Server slow or unreachable — will retry");
|
|
433
|
-
}
|
|
442
|
+
// Emit user-facing error — always offer re-auth for OAuth accounts
|
|
443
|
+
const config = this.configs.get(accountId);
|
|
444
|
+
const isOAuth = !!config?.tokenProvider;
|
|
445
|
+
const hint = isOAuth ? "Authentication may have expired" : "Check server connectivity";
|
|
446
|
+
this.emit("accountError", accountId, e.message || "unknown", hint);
|
|
434
447
|
}
|
|
435
448
|
finally {
|
|
436
449
|
if (client)
|
|
@@ -544,6 +557,8 @@ export class ImapManager extends EventEmitter {
|
|
|
544
557
|
lastInboxCounts = new Map();
|
|
545
558
|
async quickInboxCheck() {
|
|
546
559
|
for (const [accountId] of this.configs) {
|
|
560
|
+
if (this.reauthenticating.has(accountId))
|
|
561
|
+
continue;
|
|
547
562
|
let client = null;
|
|
548
563
|
try {
|
|
549
564
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
@@ -1188,6 +1203,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1188
1203
|
}
|
|
1189
1204
|
const accountDir = account.imap.user.replace(/[@.]/g, "_");
|
|
1190
1205
|
const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
|
|
1206
|
+
const tokenPath = path.join(tokenDir, "contacts-token.json");
|
|
1207
|
+
// If no cached token exists, skip — don't open interactive browser consent for contacts
|
|
1208
|
+
// The user can trigger contacts sync manually or it will be set up during mailx -setup
|
|
1209
|
+
if (!fs.existsSync(tokenPath)) {
|
|
1210
|
+
console.log(` [contacts] No token for ${accountId} — skipping (run mailx -setup to authorize)`);
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1191
1213
|
const token = await authenticateOAuth(credentialsPath, {
|
|
1192
1214
|
scope: "https://www.googleapis.com/auth/contacts.readonly",
|
|
1193
1215
|
tokenDirectory: tokenDir,
|