@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 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.57",
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.13",
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
- try {
284
- const serverUids = new Set(await client.getUids(folder.path));
285
- const localUids = this.db.getUidsForFolder(accountId, folderId);
286
- for (const uid of localUids) {
287
- if (!serverUids.has(uid)) {
288
- this.db.deleteMessage(accountId, uid);
289
- // Also remove cached body
290
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
291
- deletedCount++;
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 for all accounts
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 folders) {
383
- if (folder.specialUse === "inbox")
384
- continue; // already synced
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("Sync timeout (60s)")), 60000))
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
- console.error(` [outbox] Error for ${accountId}: ${e.message}`);
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); // 3s after startup (let connections settle)
1073
- this.outboxInterval = setInterval(async () => {
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();
@@ -172,6 +172,11 @@ export type WsEvent = {
172
172
  } | {
173
173
  type: "error";
174
174
  message: string;
175
+ } | {
176
+ type: "accountError";
177
+ accountId: string;
178
+ error: string;
179
+ hint: string;
175
180
  };
176
181
  export interface MailxSettings {
177
182
  accounts: AccountConfig[];