@bobfrankston/mailx 1.0.386 → 1.0.389

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/bin/mailx.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * mailx -test Test IMAP/SMTP connectivity
16
16
  * mailx -rebuild Wipe local cache, re-sync from IMAP
17
17
  * mailx -repair Re-sync metadata (fix corrupt subjects) keeping .eml files
18
+ * mailx -reauth Clear cached OAuth tokens; next start re-consents
19
+ * (use when new Google scopes have been added)
18
20
  */
19
21
  import fs from "node:fs";
20
22
  import path from "node:path";
@@ -87,7 +89,7 @@ function pidAlive(pid) {
87
89
  // on an old UI with no indication that the install has been upgraded.
88
90
  // Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
89
91
  // the internal --daemon respawn.
90
- const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log"];
92
+ const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
91
93
  const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
92
94
  if (!isDaemon && !__isCommandInvocation) {
93
95
  const inst = readInstanceFile();
@@ -278,6 +280,39 @@ if (hasFlag("kill")) {
278
280
  console.log("No mailx processes found");
279
281
  process.exit(0);
280
282
  }
283
+ // Re-auth: clear cached OAuth tokens so the next start forces a fresh
284
+ // consent flow. Needed when scopes change (e.g. Google Tasks was added
285
+ // 2026-04-23 but existing tokens were issued against the older scope
286
+ // set, so tasks API calls 403ed with "insufficient authentication
287
+ // scopes"). Safe — tokens are only a cache; fresh consent re-issues.
288
+ if (hasFlag("reauth")) {
289
+ const { getConfigDir } = await import("@bobfrankston/mailx-settings");
290
+ const tokensDir = path.join(getConfigDir(), "tokens");
291
+ if (!fs.existsSync(tokensDir)) {
292
+ console.log("No tokens directory — nothing to clear.");
293
+ process.exit(0);
294
+ }
295
+ let cleared = 0;
296
+ for (const entry of fs.readdirSync(tokensDir)) {
297
+ const userDir = path.join(tokensDir, entry);
298
+ try {
299
+ const stat = fs.statSync(userDir);
300
+ if (!stat.isDirectory())
301
+ continue;
302
+ const tokenFile = path.join(userDir, "oauth-token.json");
303
+ if (fs.existsSync(tokenFile)) {
304
+ fs.unlinkSync(tokenFile);
305
+ console.log(` Cleared token for ${entry}`);
306
+ cleared++;
307
+ }
308
+ }
309
+ catch { /* skip */ }
310
+ }
311
+ console.log(cleared === 0
312
+ ? "No cached tokens found."
313
+ : `Cleared ${cleared} cached token(s). Next 'mailx' start will open a browser OAuth consent so the new scopes (tasks, full contacts) get granted.`);
314
+ process.exit(0);
315
+ }
281
316
  // Rebuild: wipe DB + message store, keep accounts/settings
282
317
  if (rebuildMode) {
283
318
  const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
@@ -1222,6 +1257,9 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1222
1257
  imapManager.on("tasksUpdated", (payload) => {
1223
1258
  handle.send({ _event: "tasksUpdated", type: "tasksUpdated", ...payload });
1224
1259
  });
1260
+ imapManager.on("authScopeError", (payload) => {
1261
+ handle.send({ _event: "authScopeError", type: "authScopeError", ...payload });
1262
+ });
1225
1263
  imapManager.on("bodyCached", (accountId, uid) => {
1226
1264
  pendingCached.push({ accountId, uid });
1227
1265
  if (!cachedTimer) {
@@ -1303,6 +1341,18 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1303
1341
  // initial sync finishes so IMAP accounts get instant-push new-mail (the
1304
1342
  // 5-min STATUS poll is only a safety net).
1305
1343
  if (settings.accounts.some(a => a.enabled)) {
1344
+ // Fast-path: fire a quick INBOX check on every account IMMEDIATELY,
1345
+ // parallel to the full syncAll. quickInboxCheckAccount uses a fresh
1346
+ // client + a cached folder list from the DB, so it skips the
1347
+ // folder-list fetch that syncAll's step 1 does. On a cold Dovecot
1348
+ // session that folder LIST can take several seconds on big trees
1349
+ // (bobma = ~105 folders) — no reason the user should wait for it
1350
+ // before seeing mail that arrived overnight in INBOX.
1351
+ for (const acct of settings.accounts) {
1352
+ if (!acct.enabled)
1353
+ continue;
1354
+ imapManager.quickInboxCheckAccount(acct.id).catch(e => console.error(` [startup-check] ${acct.id}: ${e?.message || e}`));
1355
+ }
1306
1356
  imapManager.syncAll()
1307
1357
  .then(() => imapManager.startWatching())
1308
1358
  .then(() => {
@@ -16,6 +16,7 @@
16
16
  import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
17
17
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
18
18
  const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
19
+ const SHOW_DONE_PREF = "mailx-task-show-done";
19
20
  const HORIZON_DAYS_PREF = "mailx-cal-horizon-days";
20
21
  const HORIZON_DEFAULT_DAYS = 30;
21
22
  let viewYear = new Date().getFullYear();
@@ -131,7 +132,8 @@ function renderEvents(events) {
131
132
  });
132
133
  }
133
134
  async function renderTasks() {
134
- const showDone = document.getElementById("cal-side-show-done")?.checked || false;
135
+ const cb = document.getElementById("cal-side-show-done");
136
+ const showDone = cb?.checked ?? false;
135
137
  const tasks = await getTasks(showDone);
136
138
  const host = document.getElementById("cal-side-tasks");
137
139
  if (!host)
@@ -248,7 +250,18 @@ export function initCalendarSidebar() {
248
250
  const showDoneCb = document.getElementById("cal-side-show-done");
249
251
  if (showDoneCb && !showDoneCb.__wired) {
250
252
  showDoneCb.__wired = true;
251
- showDoneCb.addEventListener("change", () => renderTasks());
253
+ // Sticky: restore prior state and persist on change. Default off.
254
+ try {
255
+ showDoneCb.checked = localStorage.getItem(SHOW_DONE_PREF) === "true";
256
+ }
257
+ catch { /* */ }
258
+ showDoneCb.addEventListener("change", () => {
259
+ try {
260
+ localStorage.setItem(SHOW_DONE_PREF, String(showDoneCb.checked));
261
+ }
262
+ catch { /* */ }
263
+ renderTasks();
264
+ });
252
265
  }
253
266
  // Recurring-events filter toggle — hides expanded recurring-series
254
267
  // instances when unchecked. Default on so new users see everything.
@@ -295,6 +308,17 @@ export function initCalendarSidebar() {
295
308
  refresh();
296
309
  else if (event?.type === "tasksUpdated")
297
310
  renderTasks();
311
+ else if (event?.type === "authScopeError") {
312
+ // Surface a visible hint right in the affected pane so the
313
+ // user doesn't stare at an empty list wondering why. Only
314
+ // writes to the matching pane; other panes keep rendering.
315
+ const host = event.feature === "tasks"
316
+ ? document.getElementById("cal-side-tasks")
317
+ : document.getElementById("cal-side-body");
318
+ if (host) {
319
+ host.innerHTML = `<div class="cal-side-empty cal-side-auth-error">${escapeHtml(event.message || "Google access needs re-consent.")}</div>`;
320
+ }
321
+ }
298
322
  });
299
323
  }
300
324
  }
@@ -1024,6 +1024,17 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
1024
1024
  }
1025
1025
  .cal-side-opt input[type=number] { margin-left: 4px; }
1026
1026
  .cal-side-event { cursor: pointer; }
1027
+ .cal-side-auth-error {
1028
+ color: oklch(0.55 0.18 25);
1029
+ font-weight: 600;
1030
+ padding: var(--gap-sm);
1031
+ background: oklch(0.96 0.04 25);
1032
+ border: 1px solid oklch(0.80 0.12 25);
1033
+ border-radius: var(--radius-sm);
1034
+ font-style: normal !important;
1035
+ line-height: 1.4;
1036
+ text-align: left !important;
1037
+ }
1027
1038
  .cal-side-empty {
1028
1039
  padding: var(--gap-md) var(--gap-sm);
1029
1040
  color: var(--color-text-muted);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.386",
3
+ "version": "1.0.389",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -1020,23 +1020,41 @@ export class ImapManager extends EventEmitter {
1020
1020
  }
1021
1021
  if (newCount > 0)
1022
1022
  console.log(` stored ${newCount} new messages`);
1023
- // Remove messages deleted on the server (skip on first sync — nothing to reconcile)
1023
+ // Remove messages deleted on the server (skip on first sync — nothing to reconcile).
1024
+ //
1025
+ // SAFETY (same three guards the Gmail API path uses, see ~line 1388):
1026
+ // 1. Skip if server returned an empty list but we have local messages
1027
+ // (transient Dovecot error / connection hiccup returning empty UID SEARCH
1028
+ // must not wipe the folder).
1029
+ // 2. Refuse to delete more than 50% in one pass — indicates a sync bug,
1030
+ // never a real user action. User can fix with `mailx -rebuild` if real.
1031
+ // 3. Log every deletion with Message-ID + subject so future reports have
1032
+ // data (the "ubiquiti letter disappeared after reply" case had no trace).
1024
1033
  let deletedCount = 0;
1025
1034
  if (!firstSync) {
1026
1035
  try {
1027
- // Reuse the passed-in client instead of opening a new connection
1028
- const serverUids = new Set(await client.getUids(folder.path));
1036
+ const serverUidsArr = await client.getUids(folder.path);
1037
+ const serverUids = new Set(serverUidsArr);
1029
1038
  const localUids = this.db.getUidsForFolder(accountId, folderId);
1030
- for (const uid of localUids) {
1031
- if (!serverUids.has(uid)) {
1032
- // Read body_path BEFORE deleting the row, then unlink.
1039
+ const toDelete = localUids.filter(uid => !serverUids.has(uid));
1040
+ if (serverUidsArr.length === 0 && localUids.length > 0) {
1041
+ console.log(` [sync] ${accountId}/${folder.path}: reconcile skipped server UID list empty but local has ${localUids.length} (treating as transient)`);
1042
+ }
1043
+ else if (localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
1044
+ console.log(` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
1045
+ }
1046
+ else {
1047
+ for (const uid of toDelete) {
1048
+ const env = this.db.getMessageByUid(accountId, uid);
1049
+ const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
1050
+ console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
1033
1051
  this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1034
1052
  this.db.deleteMessage(accountId, uid);
1035
1053
  deletedCount++;
1036
1054
  }
1055
+ if (deletedCount > 0)
1056
+ console.log(` removed ${deletedCount} deleted messages`);
1037
1057
  }
1038
- if (deletedCount > 0)
1039
- console.log(` removed ${deletedCount} deleted messages`);
1040
1058
  }
1041
1059
  catch (e) {
1042
1060
  console.error(` deletion sync error: ${e.message}`);
@@ -1394,6 +1412,9 @@ export class ImapManager extends EventEmitter {
1394
1412
  }
1395
1413
  else {
1396
1414
  for (const uid of toDelete) {
1415
+ const env = this.db.getMessageByUid(accountId, uid);
1416
+ const tag = env ? `msgid=${env.messageId || "?"} subj="${(env.subject || "").slice(0, 60)}"` : "unknown";
1417
+ console.log(` [reconcile-delete] ${accountId}/${folder.path} uid=${uid} ${tag}`);
1397
1418
  this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
1398
1419
  this.db.deleteMessage(accountId, uid);
1399
1420
  }
@@ -3323,7 +3344,11 @@ export class ImapManager extends EventEmitter {
3323
3344
  // After each full tick, refresh the UI indicator.
3324
3345
  this.emitOutboxStatus();
3325
3346
  };
3326
- setTimeout(() => processAll(), 3000);
3347
+ // First tick at 500ms so any stale .sending-HOST-DEAD_PID claim file
3348
+ // left behind by a prior crash gets recovered (renamed back to .ltr)
3349
+ // within half a second of startup — otherwise the status-queue pill
3350
+ // shows a red "1 queued" to the user until the first 10s tick passes.
3351
+ setTimeout(() => processAll(), 500);
3327
3352
  this.outboxInterval = setInterval(processAll, 10000);
3328
3353
  }
3329
3354
  /** Stop Outbox worker */
@@ -472,7 +472,16 @@ export class MailxService {
472
472
  if (changed)
473
473
  this.imapManager.emit("calendarUpdated", { accountId: acct.id });
474
474
  })
475
- .catch(e => console.error(`[calendar] refresh failed: ${e.message}`));
475
+ .catch(e => {
476
+ const msg = String(e?.message || e);
477
+ console.error(`[calendar] refresh failed: ${msg}`);
478
+ if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
479
+ this.imapManager.emit("authScopeError", {
480
+ feature: "calendar",
481
+ message: "Google Calendar access needs re-consent. Run `mailx -reauth` then restart.",
482
+ });
483
+ }
484
+ });
476
485
  return this.db.getCalendarEvents(acct.id, fromMs, toMs);
477
486
  }
478
487
  /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
@@ -481,6 +490,7 @@ export class MailxService {
481
490
  async refreshCalendarEvents(accountId, fromMs, toMs) {
482
491
  const tp = await this.primaryTokenProvider("calendar");
483
492
  const events = await gsync.listCalendarEvents(tp, fromMs, toMs);
493
+ console.log(` [calendar] pulled ${events.length} events from ${new Date(fromMs).toISOString().slice(0, 10)} to ${new Date(toMs).toISOString().slice(0, 10)}`);
484
494
  let changed = false;
485
495
  // Upsert by provider_id — dedup globally, not just within the window,
486
496
  // so an event whose start moves outside the prior query range doesn't
@@ -563,12 +573,24 @@ export class MailxService {
563
573
  if (changed)
564
574
  this.imapManager.emit("tasksUpdated", { accountId: acct.id });
565
575
  })
566
- .catch(e => console.error(`[tasks] refresh failed: ${e.message}`));
576
+ .catch(e => {
577
+ const msg = String(e?.message || e);
578
+ console.error(`[tasks] refresh failed: ${msg}`);
579
+ if (/insufficient (authentication )?scope|PERMISSION_DENIED|403/i.test(msg)) {
580
+ console.error(`[tasks] Your cached OAuth token doesn't include the 'tasks' scope.`);
581
+ console.error(`[tasks] Run 'mailx -reauth' then 'mailx' to re-consent and pick up the new scope.`);
582
+ this.imapManager.emit("authScopeError", {
583
+ feature: "tasks",
584
+ message: "Google Tasks access needs re-consent. Run `mailx -reauth` then restart.",
585
+ });
586
+ }
587
+ });
567
588
  return this.db.getTasks(acct.id, includeCompleted);
568
589
  }
569
590
  async refreshTasks(accountId, includeCompleted) {
570
591
  const tp = await this.primaryTokenProvider("tasks");
571
592
  const tasks = await gsync.listTasks(tp, "@default", includeCompleted);
593
+ console.log(` [tasks] pulled ${tasks.length} tasks`);
572
594
  const existing = this.db.getTasks(accountId, true);
573
595
  let changed = false;
574
596
  const seen = new Set();
@@ -730,11 +752,40 @@ export class MailxService {
730
752
  for (const f of fs.readdirSync(dir)) {
731
753
  if (!f.endsWith(".ltr") && !f.endsWith(".eml") && !/\.sending-/.test(f))
732
754
  continue;
755
+ const fp = path.join(dir, f);
733
756
  try {
734
- const raw = fs.readFileSync(path.join(dir, f), "utf-8");
757
+ const raw = fs.readFileSync(fp, "utf-8");
735
758
  out.push(parseEnv(raw, f, dir, accountId));
736
759
  }
737
- catch { /* unreadable — skip */ }
760
+ catch (err) {
761
+ // Unreadable file — still show it so the user can cancel.
762
+ // Previously silently skipped, which produced the user-reported
763
+ // "outbox badge shows 1 but the modal is empty" symptom:
764
+ // getOutboxStatus counted the file, listQueuedOutgoing dropped it.
765
+ const st = (() => { try {
766
+ return fs.statSync(fp);
767
+ }
768
+ catch {
769
+ return null;
770
+ } })();
771
+ out.push({
772
+ accountId,
773
+ file: f,
774
+ path: fp,
775
+ dir,
776
+ from: "",
777
+ to: "",
778
+ cc: "",
779
+ bcc: "",
780
+ subject: `[unreadable: ${err?.code || err?.message || "read failed"}]`,
781
+ date: "",
782
+ messageId: "",
783
+ attempts: 0,
784
+ sizeBytes: st?.size || 0,
785
+ createdAt: st?.mtimeMs || 0,
786
+ claimed: /\.sending-[^-]+-\d+$/.test(f),
787
+ });
788
+ }
738
789
  }
739
790
  };
740
791
  try {