@bobfrankston/mailx 1.0.389 → 1.0.392

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
@@ -907,7 +907,9 @@ async function main() {
907
907
  fs.mkdirSync(logDir, { recursive: true });
908
908
  // Prune logs older than LOG_RETENTION_DAYS on startup. Keep it simple:
909
909
  // scan the dir, stat, delete. Cheap even with years of history.
910
- const LOG_RETENTION_DAYS = 7;
910
+ // Bumped 7 → 30 days so "where did my letter go?" reports can still
911
+ // reach the `[reconcile-delete]` log entry weeks after the fact.
912
+ const LOG_RETENTION_DAYS = 30;
911
913
  const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
912
914
  try {
913
915
  for (const name of fs.readdirSync(logDir)) {
package/client/app.js CHANGED
@@ -1677,6 +1677,52 @@ onWsEvent((event) => {
1677
1677
  case "outboxStatus":
1678
1678
  renderOutboxStatus(event);
1679
1679
  break;
1680
+ case "calendarUpdated":
1681
+ case "tasksUpdated":
1682
+ // Reauth succeeded (or was never broken): clear any lingering
1683
+ // scope banner for this feature. Handled here (not just in the
1684
+ // sidebar) because the global fallback banner isn't tied to the
1685
+ // sidebar's lifecycle.
1686
+ if (alertBanner && /^scope-(calendar|tasks|google)$/.test(alertBanner.dataset.key || "")) {
1687
+ alertBanner.hidden = true;
1688
+ alertBanner.dataset.key = "";
1689
+ alertBanner.querySelector(".status-action")?.remove();
1690
+ }
1691
+ break;
1692
+ case "authScopeError": {
1693
+ // Fallback banner: calendar-sidebar.ts already shows this inline
1694
+ // when the sidebar is visible, but if the user has the sidebar
1695
+ // off or is on a narrow tier where it's hidden, the error would
1696
+ // otherwise be invisible. Global banner with the same button.
1697
+ const feat = event.feature || "google";
1698
+ const key = `scope-${feat}`;
1699
+ const msg = event.message || `Google ${feat} access needs re-consent.`;
1700
+ showAlert(msg, key, { sticky: true });
1701
+ const bannerText = document.getElementById("alert-text");
1702
+ if (bannerText && bannerText.textContent === msg) {
1703
+ const existing = bannerText.parentElement?.querySelector(".status-action");
1704
+ if (!existing) {
1705
+ const btn = document.createElement("button");
1706
+ btn.className = "status-action";
1707
+ btn.textContent = "Re-authenticate";
1708
+ btn.addEventListener("click", async () => {
1709
+ btn.disabled = true;
1710
+ btn.textContent = "Opening browser…";
1711
+ try {
1712
+ const { reauthGoogleScopes } = await import("./lib/api-client.js");
1713
+ await reauthGoogleScopes();
1714
+ btn.textContent = "Consent opened — finish in browser";
1715
+ }
1716
+ catch (err) {
1717
+ btn.disabled = false;
1718
+ btn.textContent = `Failed: ${err?.message || err}`;
1719
+ }
1720
+ });
1721
+ bannerText.parentElement?.insertBefore(btn, document.getElementById("alert-dismiss"));
1722
+ }
1723
+ }
1724
+ break;
1725
+ }
1680
1726
  case "accountError": {
1681
1727
  // Show actual error + hint in banner
1682
1728
  const msg = `${event.accountId}: ${event.error}`;
@@ -13,7 +13,7 @@
13
13
  * All storage goes through the service-side two-way cache (calendar_events
14
14
  * and tasks tables); this file does not use localStorage for data.
15
15
  */
16
- import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
16
+ import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, reauthGoogleScopes, } 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
19
  const SHOW_DONE_PREF = "mailx-task-show-done";
@@ -312,11 +312,34 @@ export function initCalendarSidebar() {
312
312
  // Surface a visible hint right in the affected pane so the
313
313
  // user doesn't stare at an empty list wondering why. Only
314
314
  // writes to the matching pane; other panes keep rendering.
315
+ //
316
+ // Idempotent: if the banner is already shown for this
317
+ // feature (class `.cal-side-auth-error` present), don't
318
+ // re-write the DOM — stops the "flashing on and off
319
+ // continually" effect when the service re-emits on every
320
+ // 5-min poll or sidebar-nav click.
315
321
  const host = event.feature === "tasks"
316
322
  ? document.getElementById("cal-side-tasks")
317
323
  : 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>`;
324
+ if (host && !host.querySelector(".cal-side-auth-error")) {
325
+ const msg = event.message || "Google access needs re-consent.";
326
+ host.innerHTML = `<div class="cal-side-empty cal-side-auth-error">
327
+ <div style="margin-bottom:0.6em">${escapeHtml(msg)}</div>
328
+ <button type="button" class="cal-side-reauth-btn" style="padding:0.3em 0.8em;border-radius:4px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer;font-size:0.9em">Re-authenticate Now</button>
329
+ </div>`;
330
+ const btn = host.querySelector(".cal-side-reauth-btn");
331
+ btn?.addEventListener("click", async () => {
332
+ btn.disabled = true;
333
+ btn.textContent = "Opening browser…";
334
+ try {
335
+ await reauthGoogleScopes();
336
+ btn.textContent = "Consent opened — complete it in the browser";
337
+ }
338
+ catch (err) {
339
+ btn.disabled = false;
340
+ btn.textContent = `Failed: ${err?.message || err}. Click to retry.`;
341
+ }
342
+ });
320
343
  }
321
344
  }
322
345
  });
@@ -467,6 +467,9 @@ async function loadFolderTree(container) {
467
467
  Email address
468
468
  <input id="setup-email" type="email" placeholder="you@gmail.com" required style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
469
469
  </label>
470
+ <div id="setup-provider-preview" style="display:none;margin-bottom:0.5rem;padding:0.4rem 0.6rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem">
471
+ <span id="setup-provider-icon" style="display:inline-block;width:1.2em;text-align:center;margin-right:0.4em"></span><span id="setup-provider-label"></span>
472
+ </div>
470
473
  <label id="setup-name-row" style="display:none;margin-bottom:0.5rem">
471
474
  Your name <span style="color:var(--color-text-muted);font-size:0.85rem">(optional — auto-detected from Google)</span>
472
475
  <input id="setup-name" type="text" placeholder="Your Name (leave blank to use Google profile)" style="display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px">
@@ -496,6 +499,23 @@ async function loadFolderTree(container) {
496
499
  "aol.com": "Use an app password: AOL Settings → Account Security → Generate app password",
497
500
  "icloud.com": "Use an app-specific password: appleid.apple.com → Sign-In and Security → App-Specific Passwords",
498
501
  };
502
+ // Q67: describe the detected provider so the user knows which
503
+ // auto-config path we're about to take BEFORE they hit Next.
504
+ // Gmail / Google Workspace domains auto-detect via MX in the
505
+ // service; here we can only name the known ones up front and
506
+ // say "will auto-detect" for everything else.
507
+ const PROVIDER_PREVIEW = {
508
+ "gmail.com": { icon: "✉", label: "Gmail — OAuth (no password needed)" },
509
+ "googlemail.com": { icon: "✉", label: "Gmail — OAuth (no password needed)" },
510
+ "outlook.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
511
+ "hotmail.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
512
+ "live.com": { icon: "✉", label: "Outlook.com — OAuth (no password needed)" },
513
+ "yahoo.com": { icon: "✉", label: "Yahoo Mail — IMAP (needs app password)" },
514
+ "aol.com": { icon: "✉", label: "AOL Mail — IMAP (needs app password)" },
515
+ "icloud.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
516
+ "me.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
517
+ "mac.com": { icon: "✉", label: "iCloud Mail — IMAP (needs app-specific password)" },
518
+ };
499
519
  let oauthAutoFired = false;
500
520
  emailInput?.addEventListener("input", () => {
501
521
  const email = emailInput.value.trim();
@@ -503,6 +523,21 @@ async function loadFolderTree(container) {
503
523
  const hasAt = email.includes("@") && domain.length > 0;
504
524
  const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
505
525
  const isGmailLike = ["gmail.com", "googlemail.com"].includes(domain);
526
+ // Provider preview row
527
+ const preview = document.getElementById("setup-provider-preview");
528
+ const icon = document.getElementById("setup-provider-icon");
529
+ const label = document.getElementById("setup-provider-label");
530
+ if (preview && icon && label) {
531
+ if (hasAt) {
532
+ const hit = PROVIDER_PREVIEW[domain];
533
+ icon.textContent = hit ? hit.icon : "❓";
534
+ label.textContent = hit ? hit.label : `${domain} — will auto-detect via MX records`;
535
+ preview.style.display = "block";
536
+ }
537
+ else {
538
+ preview.style.display = "none";
539
+ }
540
+ }
506
541
  // OAuth providers: auto-fire setup immediately once domain
507
542
  // is recognized — don't show name/password (name is auto-
508
543
  // detected from Google profile, no password needed). This
@@ -853,10 +853,8 @@ function formatSize(n) {
853
853
  document.getElementById("btn-attach")?.addEventListener("click", () => {
854
854
  fileInput?.click();
855
855
  });
856
- fileInput?.addEventListener("change", async () => {
857
- if (!fileInput.files)
858
- return;
859
- for (const file of Array.from(fileInput.files)) {
856
+ async function ingestFiles(files) {
857
+ for (const file of Array.from(files)) {
860
858
  const buf = await file.arrayBuffer();
861
859
  // base64 the whole thing — mailx-service builds the multipart/mixed
862
860
  let binary = "";
@@ -871,10 +869,61 @@ fileInput?.addEventListener("change", async () => {
871
869
  dataBase64,
872
870
  });
873
871
  }
874
- fileInput.value = "";
875
872
  renderAttachmentChips();
876
873
  scheduleDraftSave();
874
+ }
875
+ fileInput?.addEventListener("change", async () => {
876
+ if (!fileInput.files)
877
+ return;
878
+ await ingestFiles(fileInput.files);
879
+ fileInput.value = "";
877
880
  });
881
+ // Drag-and-drop: dropping files anywhere on the compose window attaches them.
882
+ // Highlights a subtle overlay while dragging so the target is obvious. The
883
+ // editor iframe swallows drag events internally so we attach to the compose
884
+ // document root; Quill's own paste/drop handling doesn't fight us because
885
+ // files-with-no-HTML-or-text dragover never hits Quill's clipboard module.
886
+ (() => {
887
+ let dragDepth = 0;
888
+ const root = document.body;
889
+ const overlay = document.createElement("div");
890
+ overlay.id = "compose-drop-overlay";
891
+ overlay.hidden = true;
892
+ overlay.style.cssText = "position:fixed;inset:0;background:oklch(0.6 0.18 250 / 0.15);border:3px dashed oklch(0.55 0.2 250);z-index:9999;pointer-events:none;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:500;color:oklch(0.35 0.2 250)";
893
+ overlay.textContent = "Drop files to attach";
894
+ root.appendChild(overlay);
895
+ const hasFiles = (e) => Array.from(e.dataTransfer?.types || []).includes("Files");
896
+ root.addEventListener("dragenter", (e) => {
897
+ if (!hasFiles(e))
898
+ return;
899
+ dragDepth++;
900
+ overlay.hidden = false;
901
+ });
902
+ root.addEventListener("dragleave", (e) => {
903
+ if (!hasFiles(e))
904
+ return;
905
+ dragDepth = Math.max(0, dragDepth - 1);
906
+ if (dragDepth === 0)
907
+ overlay.hidden = true;
908
+ });
909
+ root.addEventListener("dragover", (e) => {
910
+ if (!hasFiles(e))
911
+ return;
912
+ e.preventDefault(); // required so drop fires
913
+ if (e.dataTransfer)
914
+ e.dataTransfer.dropEffect = "copy";
915
+ });
916
+ root.addEventListener("drop", async (e) => {
917
+ if (!hasFiles(e))
918
+ return;
919
+ e.preventDefault();
920
+ dragDepth = 0;
921
+ overlay.hidden = true;
922
+ const files = e.dataTransfer?.files;
923
+ if (files && files.length > 0)
924
+ await ingestFiles(files);
925
+ });
926
+ })();
878
927
  // ── Save and close (X button from parent) ──
879
928
  window.addEventListener("compose-save-and-close", () => {
880
929
  handleCloseRequest();
@@ -128,6 +128,9 @@ export function syncAccount(accountId) {
128
128
  export function reauthenticate(accountId) {
129
129
  return ipc().reauthenticate(accountId);
130
130
  }
131
+ export function reauthGoogleScopes() {
132
+ return ipc().reauthGoogleScopes();
133
+ }
131
134
  export function getSyncPending() {
132
135
  return ipc().getSyncPending();
133
136
  }
@@ -180,6 +180,7 @@
180
180
  listQueuedOutgoing: function() { return callNode("listQueuedOutgoing"); },
181
181
  cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
182
182
  reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
183
+ reauthGoogleScopes: function() { return callNode("reauthGoogleScopes"); },
183
184
 
184
185
  // Bulk operations
185
186
  deleteMessages: function(accountId, uids) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.389",
3
+ "version": "1.0.392",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -3067,18 +3067,50 @@ export class ImapManager extends EventEmitter {
3067
3067
  }
3068
3068
  return;
3069
3069
  }
3070
- // IMAP accounts: append to IMAP Outbox for multi-machine interlock
3070
+ // IMAP accounts: append to IMAP Outbox for multi-machine interlock.
3071
+ //
3072
+ // Atomic claim (same pattern as the Gmail path above): rename the file
3073
+ // to <file>.sending-<host>-<pid> BEFORE reading it, so two concurrent
3074
+ // mailx instances scanning the same dir can't both APPEND the same
3075
+ // message to IMAP Outbox and end up with a duplicate. Filesystem rename
3076
+ // is atomic; the loser sees ENOENT and skips. On APPEND success, move
3077
+ // the claimed file to sending/sent/; on APPEND failure, release the
3078
+ // claim so the recovery sweeper picks it up next tick.
3071
3079
  try {
3072
3080
  const outboxPath = await this.ensureOutbox(accountId);
3073
3081
  const client = await this.createClientWithLimit(accountId);
3074
3082
  try {
3075
3083
  for (const { dir, file } of filesToSend) {
3076
3084
  const filePath = path.join(dir, file);
3077
- const raw = fs.readFileSync(filePath, "utf-8");
3078
- await client.appendMessage(outboxPath, raw, ["\\Seen"]);
3079
- // Move to sent/
3080
- fs.renameSync(filePath, path.join(sentDir, file));
3081
- console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
3085
+ const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
3086
+ const claimedPath = filePath + claimSuffix;
3087
+ try {
3088
+ fs.renameSync(filePath, claimedPath);
3089
+ }
3090
+ catch (e) {
3091
+ if (e.code === "ENOENT")
3092
+ continue; // sibling claimed first
3093
+ throw e;
3094
+ }
3095
+ try {
3096
+ const raw = fs.readFileSync(claimedPath, "utf-8");
3097
+ await client.appendMessage(outboxPath, raw, ["\\Seen"]);
3098
+ fs.renameSync(claimedPath, path.join(sentDir, file));
3099
+ console.log(` [outbox] Moved ${file} to IMAP Outbox → sent/`);
3100
+ }
3101
+ catch (e) {
3102
+ // APPEND failed (connection dropped mid-send, server
3103
+ // busy, etc.) — release the claim so next tick can
3104
+ // retry. Don't swallow: rethrow after release so the
3105
+ // outer catch ("IMAP still unreachable") bails out of
3106
+ // the remaining files too — whatever broke will break
3107
+ // the next file the same way.
3108
+ try {
3109
+ fs.renameSync(claimedPath, filePath);
3110
+ }
3111
+ catch { /* recovery sweeper will handle */ }
3112
+ throw e;
3113
+ }
3082
3114
  }
3083
3115
  }
3084
3116
  finally {
@@ -47,6 +47,20 @@ export declare class MailxService {
47
47
  * Called without `feature` it returns the catch-all primary — same
48
48
  * semantics as the original single-flag version for back-compat. */
49
49
  getPrimaryAccount(feature?: string): any;
50
+ /** Feature names that have already emitted authScopeError this session.
51
+ * Stops the "banner flashing on and off continually" loop where every
52
+ * 5-min poll / sidebar nav re-fired the event and the client re-rendered
53
+ * the red banner. Cleared when the user hits Re-authenticate. */
54
+ private scopeErrorEmitted;
55
+ /** Delete the cached Google OAuth token (the one used for Calendar / Tasks
56
+ * / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
57
+ * and clear the sticky auth-error state so a subsequent refresh can
58
+ * re-trigger browser consent with the current scope set. Equivalent of
59
+ * `mailx -reauth` but callable from the UI. Returns `{ cleared: N }` so
60
+ * the caller can tell the user what happened. */
61
+ reauthGoogleScopes(): {
62
+ cleared: number;
63
+ };
50
64
  private primaryTokenProvider;
51
65
  /** Return cal events visible in [fromMs..toMs), refreshing from Google
52
66
  * in the background. Caller displays local results immediately; after
@@ -447,6 +447,46 @@ export class MailxService {
447
447
  return all.find((a) => a.primary) || all[0] || null;
448
448
  }
449
449
  // ── Calendar / Tasks / Contacts: two-way cache (2026-04-23) ──
450
+ /** Feature names that have already emitted authScopeError this session.
451
+ * Stops the "banner flashing on and off continually" loop where every
452
+ * 5-min poll / sidebar nav re-fired the event and the client re-rendered
453
+ * the red banner. Cleared when the user hits Re-authenticate. */
454
+ scopeErrorEmitted = new Set();
455
+ /** Delete the cached Google OAuth token (the one used for Calendar / Tasks
456
+ * / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
457
+ * and clear the sticky auth-error state so a subsequent refresh can
458
+ * re-trigger browser consent with the current scope set. Equivalent of
459
+ * `mailx -reauth` but callable from the UI. Returns `{ cleared: N }` so
460
+ * the caller can tell the user what happened. */
461
+ reauthGoogleScopes() {
462
+ const tokensDir = path.join(getConfigDir(), "tokens");
463
+ let cleared = 0;
464
+ if (fs.existsSync(tokensDir)) {
465
+ for (const entry of fs.readdirSync(tokensDir)) {
466
+ const userDir = path.join(tokensDir, entry);
467
+ try {
468
+ if (!fs.statSync(userDir).isDirectory())
469
+ continue;
470
+ const tokenFile = path.join(userDir, "oauth-token.json");
471
+ if (fs.existsSync(tokenFile)) {
472
+ fs.unlinkSync(tokenFile);
473
+ console.log(` [reauth-google] cleared ${tokenFile}`);
474
+ cleared++;
475
+ }
476
+ }
477
+ catch { /* skip */ }
478
+ }
479
+ }
480
+ // Reset the sticky set so the next failure (if re-consent didn't take)
481
+ // can fire a fresh banner. Also trigger a kickoff refresh so the
482
+ // browser consent pops open now instead of on next sidebar nav.
483
+ this.scopeErrorEmitted.clear();
484
+ const now = Date.now();
485
+ const horizonMs = 90 * 86400_000;
486
+ this.getCalendarEvents(now, now + horizonMs); // fire-and-forget — triggers consent via primaryTokenProvider
487
+ this.getTasks(false); // same path, `tasks` scope
488
+ return { cleared };
489
+ }
450
490
  async primaryTokenProvider(feature) {
451
491
  const acct = this.getPrimaryAccount(feature);
452
492
  if (!acct)
@@ -476,10 +516,13 @@ export class MailxService {
476
516
  const msg = String(e?.message || e);
477
517
  console.error(`[calendar] refresh failed: ${msg}`);
478
518
  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
- });
519
+ if (!this.scopeErrorEmitted.has("calendar")) {
520
+ this.scopeErrorEmitted.add("calendar");
521
+ this.imapManager.emit("authScopeError", {
522
+ feature: "calendar",
523
+ message: "Google Calendar access needs re-consent.",
524
+ });
525
+ }
483
526
  }
484
527
  });
485
528
  return this.db.getCalendarEvents(acct.id, fromMs, toMs);
@@ -577,12 +620,14 @@ export class MailxService {
577
620
  const msg = String(e?.message || e);
578
621
  console.error(`[tasks] refresh failed: ${msg}`);
579
622
  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
- });
623
+ if (!this.scopeErrorEmitted.has("tasks")) {
624
+ this.scopeErrorEmitted.add("tasks");
625
+ console.error(`[tasks] Your cached OAuth token doesn't include the 'tasks' scope.`);
626
+ this.imapManager.emit("authScopeError", {
627
+ feature: "tasks",
628
+ message: "Google Tasks access needs re-consent.",
629
+ });
630
+ }
586
631
  }
587
632
  });
588
633
  return this.db.getTasks(acct.id, includeCompleted);
@@ -133,6 +133,8 @@ async function dispatchAction(svc, action, p) {
133
133
  return svc.cancelQueuedOutgoing(p.path);
134
134
  case "reauthenticate":
135
135
  return { ok: await svc.reauthenticate(p.accountId) };
136
+ case "reauthGoogleScopes":
137
+ return svc.reauthGoogleScopes();
136
138
  // Search & contacts
137
139
  case "searchMessages":
138
140
  return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);