@bobfrankston/mailx 1.0.265 → 1.0.283

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
@@ -463,16 +463,18 @@ async function promptForAccount(intro) {
463
463
  smtp: { host: smtpHost },
464
464
  };
465
465
  }
466
- /** Interactive first-time setup — GDrive API for cloud storage */
466
+ /** Interactive first-time setup — GDrive API for cloud storage.
467
+ * Returns true if an account was added (caller should proceed to launch UI),
468
+ * false if the user skipped (UI's in-browser setup form will take over). */
467
469
  async function runSetup(providedEmail) {
468
470
  console.log("\nmailx — first-time setup\n");
469
471
  const home = process.env.USERPROFILE || process.env.HOME || "";
470
472
  const mailxDir = path.join(home, ".mailx");
473
+ fs.mkdirSync(mailxDir, { recursive: true });
471
474
  // Use --email flag or prompt interactively
472
475
  const email = providedEmail || await prompt("Email address (Gmail recommended): ");
473
476
  if (!email || !email.includes("@")) {
474
477
  console.log(`\nNo account added. The UI will show a setup form.`);
475
- fs.mkdirSync(mailxDir, { recursive: true });
476
478
  return false;
477
479
  }
478
480
  if (providedEmail)
@@ -493,15 +495,15 @@ async function runSetup(providedEmail) {
493
495
  catch { /* DNS lookup failed */ }
494
496
  }
495
497
  // For Google-hosted accounts, check Drive for existing settings first
498
+ let driveFolderId = null;
496
499
  if (isGoogle) {
497
- fs.mkdirSync(mailxDir, { recursive: true });
498
500
  console.log("\nChecking Google Drive for existing mailx settings...");
499
501
  try {
500
502
  const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
501
- const folderId = await gDriveFindOrCreateFolder();
502
- if (folderId) {
503
- console.log(` Drive folder: My Drive/mailx/ (${folderId})`);
504
- const gdrive = getCloudProvider("gdrive", folderId);
503
+ driveFolderId = await gDriveFindOrCreateFolder();
504
+ if (driveFolderId) {
505
+ console.log(` Drive folder: My Drive/mailx/ (${driveFolderId})`);
506
+ const gdrive = getCloudProvider("gdrive", driveFolderId);
505
507
  if (gdrive) {
506
508
  // Read accounts.jsonc (canonical) — ignore legacy settings.jsonc
507
509
  const existing = await gdrive.read("accounts.jsonc");
@@ -514,7 +516,7 @@ async function runSetup(providedEmail) {
514
516
  for (const a of accts)
515
517
  console.log(` • ${a.label || a.name || a.email}`);
516
518
  // Save config pointing to Drive — no prompts needed
517
- const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
519
+ const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId: driveFolderId } };
518
520
  fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
519
521
  console.log("Local config created. Starting mailx...\n");
520
522
  return true;
@@ -522,12 +524,16 @@ async function runSetup(providedEmail) {
522
524
  }
523
525
  }
524
526
  // No existing accounts — save Drive config for later
525
- const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
527
+ const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId: driveFolderId } };
526
528
  fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
527
529
  }
530
+ else {
531
+ console.log(" Could not access Google Drive (OAuth not granted or token expired).");
532
+ console.log(" Account will be saved locally; the UI will retry the cloud sync when you fix Drive access.");
533
+ }
528
534
  }
529
535
  catch (e) {
530
- console.log(` Drive check failed: ${e.message} — continuing with manual setup`);
536
+ console.log(` Drive check failed: ${e.message} — will save locally and retry from UI.`);
531
537
  }
532
538
  }
533
539
  // No existing accounts found — build a new account
@@ -536,42 +542,35 @@ async function runSetup(providedEmail) {
536
542
  if (!isOAuth) {
537
543
  account.password = await prompt("Password (app password for Yahoo/AOL/iCloud): ");
538
544
  }
539
- const name = await prompt(`Your name (for From: header) [${email.split("@")[0]}]: `) || email.split("@")[0];
540
- fs.mkdirSync(mailxDir, { recursive: true });
541
- if (isGoogle) {
545
+ // Display name: leave empty for Google accounts so MailxService.setupAccount
546
+ // (or the next launch's IMAP auth) can resolve it from the People API once
547
+ // the Gmail OAuth token exists. The Drive token alone doesn't have the
548
+ // contacts.readonly scope, so we can't fetch it here at CLI-prompt time.
549
+ const defaultName = email.split("@")[0];
550
+ const name = await prompt(`Your name (for From: header) [auto-detect from Google, or '${defaultName}']: `) || (isGoogle ? "" : defaultName);
551
+ // ALWAYS write a local copy first so the data is never lost. The cloud
552
+ // write below is the sync, not the source of truth on this machine.
553
+ const accountsData = { name, accounts: [account] };
554
+ const localAccountsPath = path.join(mailxDir, "accounts.jsonc");
555
+ fs.writeFileSync(localAccountsPath, JSON.stringify(accountsData, null, 2));
556
+ if (isGoogle && driveFolderId) {
542
557
  // Save to Google Drive via API
543
558
  console.log("\nSaving account to Google Drive...");
544
559
  try {
545
- const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
546
- const folderId = await gDriveFindOrCreateFolder();
547
- if (folderId) {
548
- const gdrive = getCloudProvider("gdrive", folderId);
549
- if (gdrive) {
550
- const accountsData = { name, accounts: [account] };
551
- const ok = await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
552
- if (ok) {
553
- console.log("Account saved to Google Drive.");
554
- // config.jsonc may already exist from the Drive check above
555
- if (!fs.existsSync(path.join(mailxDir, "config.jsonc"))) {
556
- const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
557
- fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
558
- }
559
- }
560
- else {
561
- console.log("Drive write failed — saving locally.");
562
- fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
563
- }
564
- }
565
- }
560
+ const { getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
561
+ const gdrive = getCloudProvider("gdrive", driveFolderId);
562
+ if (!gdrive)
563
+ throw new Error("getCloudProvider returned null");
564
+ await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
565
+ console.log(" Account saved to Google Drive.");
566
566
  }
567
567
  catch (e) {
568
- console.log(`Drive error: ${e.message} — saving locally.`);
569
- fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
568
+ console.log(` Drive write failed: ${e.message}`);
569
+ console.log(` Local copy saved at ${localAccountsPath} UI will retry cloud sync.`);
570
570
  }
571
571
  }
572
- else {
573
- // Non-Google save locally
574
- fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
572
+ else if (isGoogle && !driveFolderId) {
573
+ console.log(` Skipping Drive sync (no folder ID). Local copy at ${localAccountsPath}.`);
575
574
  }
576
575
  console.log("Setup complete. Starting mailx...\n");
577
576
  return true;
@@ -709,10 +708,15 @@ async function registerClient(settings) {
709
708
  ip: localIp,
710
709
  version: JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version,
711
710
  };
712
- // Write back
713
- const ok = await cloudWrite("clients.jsonc", JSON.stringify(clients, null, 2));
714
- if (ok)
711
+ // Write back. cloudWrite now throws on failure (and sets lastCloudError),
712
+ // so swallow here registerClient is fire-and-forget from the caller.
713
+ try {
714
+ await cloudWrite("clients.jsonc", JSON.stringify(clients, null, 2));
715
715
  console.log(` [client] Registered device: ${deviceId}`);
716
+ }
717
+ catch (e) {
718
+ console.error(` [client] Failed to register device: ${e.message}`);
719
+ }
716
720
  }
717
721
  async function main() {
718
722
  log(`Platform: ${process.platform} ${process.arch}`);
@@ -746,7 +750,12 @@ async function main() {
746
750
  }
747
751
  process.exit(0);
748
752
  }
749
- // Auto-detect first run — enter setup if no config exists
753
+ // Auto-detect first run — enter setup if no config exists.
754
+ // Skip CLI prompts entirely when stdin isn't a TTY (auto-detached daemon
755
+ // has stdio:"ignore", so prompt() returns "" instantly and the user never
756
+ // gets to type their email — silent no-setup). The in-browser setup form
757
+ // takes over in that case.
758
+ const hasTty = setupMode ? !!process.stdin.isTTY : (process.stdin.isTTY === true);
750
759
  if (setupMode || !hasConfig()) {
751
760
  if (!setupMode)
752
761
  console.log("No mailx configuration found.");
@@ -754,7 +763,15 @@ async function main() {
754
763
  const emailFlag = args.findIndex(a => a === "-email" || a === "--email" || a === "-mail" || a === "--mail");
755
764
  const emailArg = args.find(a => a.startsWith("-email=") || a.startsWith("--email=") || a.startsWith("-mail=") || a.startsWith("--mail="))?.split("=")[1]
756
765
  || (emailFlag >= 0 ? args[emailFlag + 1] : undefined);
757
- await runSetup(emailArg);
766
+ if (hasTty || emailArg) {
767
+ await runSetup(emailArg);
768
+ }
769
+ else {
770
+ console.log("No TTY and no -email flag — skipping CLI setup; in-browser setup form will appear.");
771
+ // Ensure the data dir exists so the UI can write its config.
772
+ const home = process.env.USERPROFILE || process.env.HOME || "";
773
+ fs.mkdirSync(path.join(home, ".mailx"), { recursive: true });
774
+ }
758
775
  }
759
776
  // Redirect console to log file — keep terminal clean
760
777
  if (!verbose) {
@@ -906,6 +923,39 @@ async function main() {
906
923
  imapManager.on("configChanged", (filename) => {
907
924
  handle.send({ _event: "configChanged", type: "configChanged", filename });
908
925
  });
926
+ // syncComplete drives the folder-tree refresh that picks up newly-discovered
927
+ // folders on first run (Gmail accounts have no folders in the DB until the
928
+ // first sync fetches the labels). Without this forward, the UI shows the
929
+ // account but no folders, and never auto-selects the inbox.
930
+ imapManager.on("syncComplete", (accountId) => {
931
+ handle.send({ _event: "syncComplete", type: "syncComplete", accountId });
932
+ });
933
+ // Cloud-write/read failures from mailx-settings → push to UI as a banner so
934
+ // silent fall-back-to-local can no longer swallow Drive errors.
935
+ const { onCloudError } = await import("@bobfrankston/mailx-settings");
936
+ onCloudError((error, ctx) => {
937
+ if (error) {
938
+ handle.send({ _event: "cloudError", type: "cloudError", error, op: ctx?.op, filename: ctx?.filename });
939
+ }
940
+ else {
941
+ handle.send({ _event: "cloudError", type: "cloudError", error: null });
942
+ }
943
+ });
944
+ // Coalesce bodyCached into batches so a prefetch storm doesn't flood stdin
945
+ // with one IPC write per message — lets the UI flip many rows at once.
946
+ let pendingCached = [];
947
+ let cachedTimer = null;
948
+ imapManager.on("bodyCached", (accountId, uid) => {
949
+ pendingCached.push({ accountId, uid });
950
+ if (!cachedTimer) {
951
+ cachedTimer = setTimeout(() => {
952
+ cachedTimer = null;
953
+ const batch = pendingCached;
954
+ pendingCached = [];
955
+ handle.send({ _event: "bodyCached", type: "bodyCached", items: batch });
956
+ }, 500);
957
+ }
958
+ });
909
959
  // Brief pause for WebView2 to initialize before starting IMAP (avoids stdin writes during init)
910
960
  await new Promise(r => setTimeout(r, 500));
911
961
  // Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
@@ -920,11 +970,61 @@ async function main() {
920
970
  console.error(` Failed: ${account.id}: ${e.message}`);
921
971
  }
922
972
  }
973
+ // After OAuth has completed, resolve missing display names for Google
974
+ // accounts via the People API (contacts.readonly is in the Gmail scope).
975
+ // "Missing" = empty or matches the email local-part default.
976
+ try {
977
+ const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
978
+ const { saveAccounts } = await import("@bobfrankston/mailx-settings");
979
+ let updated = false;
980
+ for (const acct of settings.accounts) {
981
+ if (!acct.enabled)
982
+ continue;
983
+ const isGoogle = acct.email.endsWith("@gmail.com")
984
+ || acct.email.endsWith("@googlemail.com")
985
+ || acct.imap?.host?.includes("gmail");
986
+ if (!isGoogle)
987
+ continue;
988
+ const local = acct.email.split("@")[0];
989
+ const looksDefault = !acct.name || acct.name === local;
990
+ if (!looksDefault)
991
+ continue;
992
+ try {
993
+ const tok = await imapManager.getOAuthToken(acct.id);
994
+ if (!tok)
995
+ continue;
996
+ const profile = await getGoogleProfile(tok);
997
+ if (profile?.name && profile.name !== acct.name) {
998
+ console.log(` [name-resolve] ${acct.id}: '${acct.name || "(empty)"}' → '${profile.name}'`);
999
+ acct.name = profile.name;
1000
+ updated = true;
1001
+ }
1002
+ }
1003
+ catch (e) {
1004
+ console.error(` [name-resolve] ${acct.id}: ${e.message}`);
1005
+ }
1006
+ }
1007
+ if (updated) {
1008
+ try {
1009
+ await saveAccounts(settings.accounts);
1010
+ }
1011
+ catch (e) {
1012
+ console.error(` [name-resolve] saveAccounts failed: ${e.message}`);
1013
+ }
1014
+ }
1015
+ }
1016
+ catch (e) {
1017
+ console.error(` [name-resolve] init failed: ${e.message}`);
1018
+ }
923
1019
  // Register this client device on GDrive (fire-and-forget)
924
1020
  registerClient(settings).catch(() => { });
925
- // Start sync in background — don't block
1021
+ // Start sync in background — don't block. Kick off IDLE watchers once the
1022
+ // initial sync finishes so IMAP accounts get instant-push new-mail (the
1023
+ // 5-min STATUS poll is only a safety net).
926
1024
  if (settings.accounts.some(a => a.enabled)) {
927
- imapManager.syncAll().catch(e => console.error(` Sync error: ${e.message}`));
1025
+ imapManager.syncAll()
1026
+ .then(() => imapManager.startWatching())
1027
+ .catch(e => console.error(` Sync error: ${e.message}`));
928
1028
  }
929
1029
  imapManager.startPeriodicSync(settings.sync.intervalMinutes);
930
1030
  imapManager.startOutboxWorker();
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":707,"y":89}
1
+ {"height":1421,"width":2151,"x":228,"y":93}
@@ -148,6 +148,7 @@
148
148
  <button class="tb-btn" id="btn-reply-all" title="Reply All">↩↩</button>
149
149
  <button class="tb-btn" id="btn-forward" title="Forward">→</button>
150
150
  <button class="tb-btn" id="btn-delete" title="Delete">🗑</button>
151
+ <button class="tb-btn" id="btn-spam" title="Mark as spam" hidden>⚠</button>
151
152
  <button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
152
153
  <span style="flex:1"></span>
153
154
  <button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
package/client/app.js CHANGED
@@ -3,9 +3,9 @@
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
5
  import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
6
- import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
6
+ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages } from "./lib/api-client.js";
9
9
  import * as messageState from "./lib/message-state.js";
10
10
  // ── New message badge (favicon + title) ──
11
11
  let baseTitle = "mailx";
@@ -203,6 +203,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
203
203
  loadMessages(accountId, folderId, 1, specialUse);
204
204
  setTitle(`mailx - ${folderName}`);
205
205
  setNarrowFolderTitle(folderName);
206
+ document.dispatchEvent(new CustomEvent("mailx-folder-changed", { detail: { accountId, folderId } }));
206
207
  }, () => {
207
208
  // Unified inbox handler
208
209
  currentFolderSpecialUse = "inbox";
@@ -690,6 +691,58 @@ document.addEventListener("mailx-moved", (e) => {
690
691
  undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
691
692
  });
692
693
  document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
694
+ async function spamSelectedMessages() {
695
+ const selected = getSelectedMessages();
696
+ if (selected.length === 0) {
697
+ const current = getCurrentMessage();
698
+ if (!current)
699
+ return;
700
+ selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
701
+ }
702
+ const statusSync = document.getElementById("status-sync");
703
+ try {
704
+ const byAccount = new Map();
705
+ for (const msg of selected) {
706
+ const uids = byAccount.get(msg.accountId) || [];
707
+ uids.push(msg.uid);
708
+ byAccount.set(msg.accountId, uids);
709
+ }
710
+ for (const [accountId, uids] of byAccount) {
711
+ await markAsSpamMessages(accountId, uids);
712
+ }
713
+ if (statusSync)
714
+ statusSync.textContent = `Marked ${selected.length} as spam`;
715
+ messageState.removeMessages(selected);
716
+ }
717
+ catch (e) {
718
+ if (statusSync)
719
+ statusSync.textContent = `Spam failed: ${e.message}`;
720
+ console.error(`Spam failed: ${e.message}`);
721
+ }
722
+ }
723
+ document.getElementById("btn-spam")?.addEventListener("click", spamSelectedMessages);
724
+ /** Show/hide the Spam button based on whether the current account has "spam" configured. */
725
+ async function refreshSpamButtonVisibility() {
726
+ const btn = document.getElementById("btn-spam");
727
+ if (!btn)
728
+ return;
729
+ const current = getCurrentMessage();
730
+ const accountId = current?.accountId || currentAccountId;
731
+ if (!accountId) {
732
+ btn.hidden = true;
733
+ return;
734
+ }
735
+ try {
736
+ const accounts = await getAccounts();
737
+ const acct = accounts.find((a) => a.id === accountId);
738
+ btn.hidden = !acct?.spam;
739
+ }
740
+ catch {
741
+ btn.hidden = true;
742
+ }
743
+ }
744
+ document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
745
+ document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
693
746
  document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
694
747
  document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
695
748
  document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
@@ -831,18 +884,54 @@ window.addEventListener("message", (e) => {
831
884
  }
832
885
  }
833
886
  if (e.data?.type === "linkHover") {
834
- const statusEl = document.getElementById("status-sync");
835
- if (statusEl) {
836
- if (e.data.url) {
837
- statusEl.textContent = e.data.url;
838
- statusEl.style.color = "var(--color-text-muted)";
887
+ let pop = document.getElementById("link-hover-popover");
888
+ if (!e.data.url) {
889
+ if (pop)
890
+ pop.style.display = "none";
891
+ }
892
+ else {
893
+ if (!pop) {
894
+ pop = document.createElement("div");
895
+ pop.id = "link-hover-popover";
896
+ pop.style.cssText = "position:fixed;z-index:10000;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;";
897
+ document.body.appendChild(pop);
839
898
  }
840
- else {
841
- statusEl.textContent = "";
842
- statusEl.style.color = "";
899
+ pop.textContent = e.data.url;
900
+ pop.style.display = "block";
901
+ // Locate the iframe whose contentWindow matches e.source so we can
902
+ // translate iframe-local rect coords into viewport coords.
903
+ let iframeRect = null;
904
+ for (const f of Array.from(document.querySelectorAll("iframe"))) {
905
+ if (f.contentWindow === e.source) {
906
+ iframeRect = f.getBoundingClientRect();
907
+ break;
908
+ }
909
+ }
910
+ const r = e.data.rect;
911
+ if (iframeRect && r) {
912
+ const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));
913
+ let y = iframeRect.top + r.bottom + 4;
914
+ // If it would clip bottom, flip above the link
915
+ if (y + 60 > window.innerHeight)
916
+ y = Math.max(4, iframeRect.top + r.top - 60);
917
+ pop.style.left = x + "px";
918
+ pop.style.top = y + "px";
843
919
  }
844
920
  }
845
921
  }
922
+ if (e.data?.type === "previewKey" && typeof e.data.key === "string") {
923
+ // Re-dispatch as a real keydown on document so the hotkey handler
924
+ // below runs the same code path as a list-focused keypress. Used
925
+ // when focus is inside the sandboxed preview iframe — works on
926
+ // platforms where parent-side contentDocument listeners don't.
927
+ const ev = new KeyboardEvent("keydown", {
928
+ key: e.data.key, code: e.data.code || "",
929
+ ctrlKey: !!e.data.ctrlKey, shiftKey: !!e.data.shiftKey,
930
+ altKey: !!e.data.altKey, metaKey: !!e.data.metaKey,
931
+ bubbles: true, cancelable: true,
932
+ });
933
+ document.dispatchEvent(ev);
934
+ }
846
935
  });
847
936
  // ── Splitter drag ──
848
937
  const splitter = document.getElementById("splitter-h");
@@ -954,6 +1043,11 @@ onWsEvent((event) => {
954
1043
  case "reload":
955
1044
  location.reload();
956
1045
  break;
1046
+ case "bodyCached":
1047
+ // Prefetch (or on-demand fetch) downloaded a body — flip the
1048
+ // "not-downloaded" indicator to the teal dot for any rows in view.
1049
+ markBodiesCached(event.items || []);
1050
+ break;
957
1051
  case "configChanged":
958
1052
  // A watched config file was modified — could be user edit via the
959
1053
  // JSONC editor, a GDrive sync, or mailx itself saving (e.g.
@@ -967,6 +1061,22 @@ onWsEvent((event) => {
967
1061
  }, 8000);
968
1062
  }
969
1063
  break;
1064
+ case "cloudError":
1065
+ // Cloud read/write failed (Google Drive auth/network/etc.). Show a
1066
+ // sticky banner so the user knows the change wasn't synced. When
1067
+ // error is null, the next successful op cleared it — hide it.
1068
+ if (event.error) {
1069
+ const where = event.filename ? ` (${event.op || "sync"} ${event.filename})` : "";
1070
+ showAlert(`Cloud sync error${where}: ${event.error}`, "cloud-error");
1071
+ }
1072
+ else {
1073
+ // Only hide if the visible banner is the cloud-error one
1074
+ if (alertBanner && alertBanner.dataset.key === "cloud-error") {
1075
+ alertBanner.hidden = true;
1076
+ dismissedAlerts.delete("cloud-error");
1077
+ }
1078
+ }
1079
+ break;
970
1080
  case "error":
971
1081
  if (statusSync)
972
1082
  statusSync.textContent = `Error: ${event.message}`;
@@ -1340,16 +1450,17 @@ optThreaded?.addEventListener("change", () => {
1340
1450
  localStorage.setItem("mailx-threaded", String(optThreaded.checked));
1341
1451
  reloadCurrentFolder();
1342
1452
  });
1343
- // Flagged-only filter
1453
+ // Flagged-only filter — keeps the CSS-level hiding for instant feedback on
1454
+ // the current page AND re-queries the folder so flagged messages that live
1455
+ // outside the currently-loaded page show up.
1344
1456
  optFlagged?.addEventListener("change", () => {
1345
1457
  const body = document.getElementById("ml-body");
1346
- if (optFlagged.checked) {
1458
+ if (optFlagged.checked)
1347
1459
  body?.classList.add("flagged-only");
1348
- }
1349
- else {
1460
+ else
1350
1461
  body?.classList.remove("flagged-only");
1351
- }
1352
1462
  localStorage.setItem("mailx-flagged", String(optFlagged.checked));
1463
+ reloadCurrentFolder();
1353
1464
  });
1354
1465
  // Folder counts toggle
1355
1466
  optFolderCounts?.addEventListener("change", () => {
@@ -450,8 +450,8 @@ async function loadFolderTree(container) {
450
450
  <p id="setup-form-intro">${introText}</p>
451
451
  <form id="setup-form" style="margin-top:1rem;${formDisplay}">
452
452
  <label style="display:block;margin-bottom:0.5rem">
453
- Your name
454
- <input id="setup-name" type="text" placeholder="Your Name" 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">
453
+ Your name <span id="setup-name-hint" style="color:var(--color-text-muted);font-size:0.85rem">(optional — auto-detected from Google)</span>
454
+ <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">
455
455
  </label>
456
456
  <label style="display:block;margin-bottom:0.5rem">
457
457
  Email address
@@ -642,11 +642,15 @@ async function loadFolderTree(container) {
642
642
  const savedScroll = container.scrollTop;
643
643
  // Build entire new tree into a DocumentFragment (off-screen, no reflows)
644
644
  const fragment = document.createDocumentFragment();
645
- // Unified Inbox (if multiple accounts)
646
- if (accounts.length > 1) {
645
+ // Unified Inbox always shown so startup auto-selects it consistently
646
+ // (with one account it's effectively that account's INBOX, but the UI
647
+ // stays uniform so the auto-select path doesn't fork on account count)
648
+ if (accounts.length >= 1) {
647
649
  const unifiedEl = document.createElement("div");
648
650
  unifiedEl.className = "ft-folder ft-unified";
649
- unifiedEl.title = "Merged inbox view of all accounts click to see messages from every account's INBOX sorted by date";
651
+ unifiedEl.title = accounts.length > 1
652
+ ? "Merged inbox view of all accounts — click to see messages from every account's INBOX sorted by date"
653
+ : "Inbox view across all your accounts";
650
654
  unifiedEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">All Inboxes</span>`;
651
655
  unifiedEl.addEventListener("click", () => {
652
656
  if (selectedElement)
@@ -16,6 +16,21 @@ let unifiedMode = false;
16
16
  let searchMode = false;
17
17
  let currentSearchQuery = "";
18
18
  let showToInsteadOfFrom = false;
19
+ let touchWasScroll = false;
20
+ /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
21
+ * Called from the bodyCached service event — covers both background prefetch
22
+ * and on-demand fetch. No-op for rows not currently rendered. */
23
+ export function markBodiesCached(items) {
24
+ const body = document.getElementById("ml-body");
25
+ if (!body || items.length === 0)
26
+ return;
27
+ for (const { accountId, uid } of items) {
28
+ const row = body.querySelector(`.ml-row[data-uid="${uid}"][data-account-id="${CSS.escape(accountId)}"]`)
29
+ || body.querySelector(`.ml-row[data-uid="${uid}"]`);
30
+ if (row)
31
+ row.classList.remove("not-downloaded");
32
+ }
33
+ }
19
34
  /** Get all selected message rows */
20
35
  export function getSelectedMessages() {
21
36
  const body = document.getElementById("ml-body");
@@ -55,6 +70,25 @@ export function initMessageList(handler) {
55
70
  // Infinite scroll
56
71
  const body = document.getElementById("ml-body");
57
72
  if (body) {
73
+ // Touch scroll vs tap: track finger movement at the container level and
74
+ // flag "we were scrolling" so row click handlers can bail out. WebView
75
+ // sometimes fires click on touchend even when the user dragged — which
76
+ // was opening a message just from scrolling the list.
77
+ let touchStartY = 0;
78
+ let touchStartX = 0;
79
+ const TAP_SLOP = 10;
80
+ body.addEventListener("touchstart", (e) => {
81
+ const t = e.touches[0];
82
+ touchStartY = t.clientY;
83
+ touchStartX = t.clientX;
84
+ touchWasScroll = false;
85
+ }, { passive: true });
86
+ body.addEventListener("touchmove", (e) => {
87
+ const t = e.touches[0];
88
+ if (Math.abs(t.clientY - touchStartY) > TAP_SLOP || Math.abs(t.clientX - touchStartX) > TAP_SLOP) {
89
+ touchWasScroll = true;
90
+ }
91
+ }, { passive: true });
58
92
  body.addEventListener("scroll", () => {
59
93
  if (loading)
60
94
  return;
@@ -236,11 +270,12 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
236
270
  body.innerHTML = `<div class="ml-empty">Loading...</div>`;
237
271
  }
238
272
  try {
239
- const result = await apiGetMessages(accountId, folderId, 1);
273
+ const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
274
+ const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly);
240
275
  totalMessages = result.total;
241
276
  if (result.items.length === 0) {
242
277
  state.setMessages([]);
243
- body.innerHTML = `<div class="ml-empty">No messages</div>`;
278
+ body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
244
279
  return;
245
280
  }
246
281
  state.setMessages(result.items);
@@ -268,11 +303,12 @@ async function loadMoreMessages() {
268
303
  loading = true;
269
304
  currentPage++;
270
305
  try {
306
+ const flaggedOnly = body.classList.contains("flagged-only");
271
307
  const result = searchMode
272
308
  ? await searchMessages(currentSearchQuery, currentPage)
273
309
  : unifiedMode
274
310
  ? await apiGetUnifiedInbox(currentPage)
275
- : await apiGetMessages(currentAccountId, currentFolderId, currentPage);
311
+ : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);
276
312
  // Append to state
277
313
  const current = state.getMessages();
278
314
  state.setMessages([...current, ...result.items]);
@@ -479,6 +515,10 @@ function appendMessages(body, accountId, items) {
479
515
  row.appendChild(date);
480
516
  row.appendChild(subject);
481
517
  row.addEventListener("click", (e) => {
518
+ if (touchWasScroll) {
519
+ touchWasScroll = false;
520
+ return;
521
+ }
482
522
  if (e.shiftKey && lastClickedRow) {
483
523
  clearSelection();
484
524
  selectRange(lastClickedRow, row);