@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 CHANGED
@@ -562,15 +562,14 @@ onWsEvent((event) => {
562
562
  showAlert(event.message, "ws-error");
563
563
  break;
564
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")) {
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
- acctEl.textContent = `${event.accountId}: reconnected`;
585
- acctEl.style.color = "";
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
- acctEl.appendChild(btn);
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.61",
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.30",
23
+ "@bobfrankston/iflow": "^1.0.31",
24
24
  "@bobfrankston/miscinfo": "^1.0.6",
25
- "@bobfrankston/oauthsupport": "^1.0.13",
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
- /** Force re-authentication for an OAuth account deletes cached token, triggers browser consent */
33
+ /** Accounts currently re-authenticatingall 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
- /** Force re-authentication for an OAuth account deletes cached token, triggers browser consent */
75
+ /** Accounts currently re-authenticatingall 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
- 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
79
+ if (this.reauthenticating.has(accountId))
80
+ return false; // already in progress
81
+ this.reauthenticating.add(accountId);
95
82
  try {
96
- const token = await this.getOAuthToken(accountId);
97
- if (token) {
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
- // 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
- }
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,