@bobfrankston/mailx 1.0.379 → 1.0.383

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
@@ -958,6 +958,18 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
958
958
  catch (e) {
959
959
  console.error(` [readme] Could not write sending README: ${e?.message || e}`);
960
960
  }
961
+ // `--server` mode — hand off to the Express HTTP server package (self-
962
+ // contained; initializes its own DB / IMAP manager / MailxService). No
963
+ // WebView2, no IPC bridge. Useful for remote access (phone before MAUI
964
+ // was a thing) and for JSON-RPC debugging from devtools. Loopback by
965
+ // default; passes --external through if the user asked for it. See C34.
966
+ if (hasFlag("server")) {
967
+ writeInstanceFile(process.pid);
968
+ await import("@bobfrankston/mailx-server");
969
+ // mailx-server's index.ts self-invokes `start()` — once imported,
970
+ // Express owns the event loop. Don't fall through to showService.
971
+ return;
972
+ }
961
973
  const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
962
974
  const imapManager = new ImapManager(db, () => new NodeTcpTransport());
963
975
  // Native client is the only option (iflow-direct)
@@ -1310,6 +1322,12 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
1310
1322
  console.error(` [contacts] periodic seed error: ${e.message}`);
1311
1323
  }
1312
1324
  }, 30 * 60_000);
1325
+ // Drain store_sync (calendar / tasks / contacts two-way pushes) every
1326
+ // 30s. Local edits also drain immediately; this picks up rows that
1327
+ // failed their first attempt (network blip, token refresh, 5xx).
1328
+ setInterval(() => {
1329
+ svc.drainStoreSync().catch((e) => console.error(` [store_sync] periodic drain error: ${e?.message || e}`));
1330
+ }, 30_000);
1313
1331
  // Auto-update: periodically check npm for a newer version and push a
1314
1332
  // notification to the WebView so the user can update with one click.
1315
1333
  const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
package/client/app.js CHANGED
@@ -82,6 +82,11 @@ async function updateNewMessageCount() {
82
82
  if (inbox)
83
83
  totalUnread += inbox.unreadCount || 0;
84
84
  }
85
+ // Rail badge: unread count on the Inbox and Unified-inbox rail buttons.
86
+ // Visible even when those views aren't the active one — part of C33
87
+ // "rail icon badges for unread counts."
88
+ updateRailBadge("rail-inbox", totalUnread);
89
+ updateRailBadge("rail-unified", totalUnread);
85
90
  // First load: set baseline
86
91
  if (lastSeenCount === 0) {
87
92
  lastSeenCount = totalUnread;
@@ -100,6 +105,23 @@ async function updateNewMessageCount() {
100
105
  }
101
106
  catch { /* offline */ }
102
107
  }
108
+ function updateRailBadge(buttonId, count) {
109
+ const btn = document.getElementById(buttonId);
110
+ if (!btn)
111
+ return;
112
+ let badge = btn.querySelector(".rail-badge");
113
+ if (count <= 0) {
114
+ if (badge)
115
+ badge.remove();
116
+ return;
117
+ }
118
+ if (!badge) {
119
+ badge = document.createElement("span");
120
+ badge.className = "rail-badge";
121
+ btn.appendChild(badge);
122
+ }
123
+ badge.textContent = count > 999 ? "999+" : String(count);
124
+ }
103
125
  // ── Taskbar flash via title alternation ──
104
126
  let titleFlashTimer = null;
105
127
  let titleFlashPhase = false;
@@ -1804,6 +1826,7 @@ const optThreaded = document.getElementById("opt-threaded");
1804
1826
  const optFlagged = document.getElementById("opt-flagged");
1805
1827
  const optFolderCounts = document.getElementById("opt-folder-counts");
1806
1828
  const optCalendarSidebar = document.getElementById("opt-calendar-sidebar");
1829
+ const optThreadFilter = document.getElementById("opt-thread-filter");
1807
1830
  // Toggle dropdown — also close any other open toolbar menu so they can't
1808
1831
  // overlap. Without this, opening View while Settings was already open left
1809
1832
  // both visible at once (user-reported screenshot).
@@ -1855,6 +1878,41 @@ if (savedFlagged)
1855
1878
  document.getElementById("ml-body")?.classList.add("flagged-only");
1856
1879
  if (savedFolderCounts)
1857
1880
  document.getElementById("folder-tree")?.classList.add("show-folder-counts");
1881
+ // "Only this conversation" toggle — hides rows whose threadId differs from
1882
+ // the currently-selected message's threadId. Client-side only (no server
1883
+ // round-trip); toggling off restores the full list. Persisted per-session
1884
+ // but not across reloads (thread context is tied to current selection).
1885
+ optThreadFilter?.addEventListener("change", () => {
1886
+ const body = document.getElementById("ml-body");
1887
+ if (!body)
1888
+ return;
1889
+ body.classList.toggle("thread-filter-on", optThreadFilter.checked);
1890
+ applyThreadFilter();
1891
+ });
1892
+ messageState.subscribe(() => applyThreadFilter());
1893
+ function applyThreadFilter() {
1894
+ const body = document.getElementById("ml-body");
1895
+ if (!body)
1896
+ return;
1897
+ if (!optThreadFilter?.checked) {
1898
+ body.querySelectorAll(".ml-row.thread-filter-hidden")
1899
+ .forEach(r => r.classList.remove("thread-filter-hidden"));
1900
+ return;
1901
+ }
1902
+ const sel = messageState.getSelected();
1903
+ const tid = sel?.threadId;
1904
+ if (!tid)
1905
+ return;
1906
+ body.querySelectorAll(".ml-row").forEach(r => {
1907
+ const rowTid = r.dataset.threadId;
1908
+ if (rowTid === tid || r.classList.contains("selected")) {
1909
+ r.classList.remove("thread-filter-hidden");
1910
+ }
1911
+ else {
1912
+ r.classList.add("thread-filter-hidden");
1913
+ }
1914
+ });
1915
+ }
1858
1916
  // S51 — Calendar sidebar: View-menu toggle, restore from localStorage,
1859
1917
  // hide auto-magically on narrow screens (CSS handles that).
1860
1918
  (async () => {
@@ -1912,6 +1970,12 @@ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () =>
1912
1970
  settingsDropdown.hidden = true;
1913
1971
  await openJsoncEditor("accounts.jsonc");
1914
1972
  });
1973
+ // Allow other components (remote-content banner, etc.) to open the editor
1974
+ // pre-selected to a specific file.
1975
+ document.addEventListener("mailx-open-jsonc-editor", async (ev) => {
1976
+ const file = ev.detail?.file || "accounts.jsonc";
1977
+ await openJsoncEditor(file);
1978
+ });
1915
1979
  // Q61: open ~/.mailx in OS file explorer.
1916
1980
  document.getElementById("btn-open-mailx-dir")?.addEventListener("click", async () => {
1917
1981
  const settingsDropdown = document.getElementById("settings-dropdown");
@@ -1959,7 +2023,10 @@ async function openJsoncEditor(initialFile) {
1959
2023
  </label>
1960
2024
  <div class="mailx-modal-split">
1961
2025
  <label class="mailx-modal-label mailx-modal-split-left">Contents (JSONC — comments and trailing commas allowed)
1962
- <textarea class="mailx-modal-input mailx-modal-textarea" id="jsonc-content" spellcheck="false"></textarea>
2026
+ <div class="jsonc-editor-wrap">
2027
+ <div class="jsonc-gutter" id="jsonc-gutter" aria-hidden="true"></div>
2028
+ <textarea class="mailx-modal-input mailx-modal-textarea jsonc-textarea" id="jsonc-content" spellcheck="false"></textarea>
2029
+ </div>
1963
2030
  </label>
1964
2031
  <div class="mailx-modal-split-right mailx-help-panel">
1965
2032
  <div class="mailx-help-title">
@@ -1978,12 +2045,31 @@ async function openJsoncEditor(initialFile) {
1978
2045
  document.body.appendChild(backdrop);
1979
2046
  const fileSelect = panel.querySelector("#jsonc-file");
1980
2047
  const textarea = panel.querySelector("#jsonc-content");
2048
+ const gutter = panel.querySelector("#jsonc-gutter");
1981
2049
  const errorEl = panel.querySelector("#jsonc-error");
1982
2050
  const saveBtn = panel.querySelector('[data-action="save"]');
1983
2051
  const helpBody = panel.querySelector("#jsonc-help-body");
1984
2052
  const helpToggle = panel.querySelector("#jsonc-help-toggle");
1985
2053
  const helpPanel = panel.querySelector(".mailx-help-panel");
1986
2054
  fileSelect.value = initialFile;
2055
+ // Line-number gutter — recomputed whenever the textarea content changes,
2056
+ // scroll-synced so numbers stay aligned. errorLine (1-based) is highlighted
2057
+ // red so the "Line N, col M" error message in the status bar points at a
2058
+ // visible marker in the gutter.
2059
+ let errorLine = 0;
2060
+ const renderGutter = () => {
2061
+ const lines = textarea.value.split("\n").length;
2062
+ let html = "";
2063
+ for (let i = 1; i <= lines; i++) {
2064
+ html += i === errorLine
2065
+ ? `<div class="jsonc-gutter-line jsonc-gutter-error">${i}</div>`
2066
+ : `<div class="jsonc-gutter-line">${i}</div>`;
2067
+ }
2068
+ gutter.innerHTML = html;
2069
+ };
2070
+ const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };
2071
+ textarea.addEventListener("scroll", syncScroll);
2072
+ textarea.addEventListener("input", renderGutter);
1987
2073
  helpToggle.addEventListener("click", () => {
1988
2074
  const open = helpPanel.classList.toggle("mailx-help-collapsed");
1989
2075
  helpToggle.textContent = open ? "▸ Help" : "▾ Help";
@@ -2005,12 +2091,16 @@ async function openJsoncEditor(initialFile) {
2005
2091
  errorEl.textContent = "";
2006
2092
  textarea.classList.remove("mailx-modal-input-error");
2007
2093
  saveBtn.disabled = false;
2094
+ errorLine = 0;
2095
+ renderGutter();
2008
2096
  };
2009
2097
  const showValidation = (err) => {
2010
2098
  errorEl.textContent = `Line ${err.line}, col ${err.col}: ${err.message}`;
2011
2099
  errorEl.hidden = false;
2012
2100
  textarea.classList.add("mailx-modal-input-error");
2013
2101
  saveBtn.disabled = true;
2102
+ errorLine = err.line;
2103
+ renderGutter();
2014
2104
  // Select the problem character so the browser draws a visible marker
2015
2105
  try {
2016
2106
  textarea.setSelectionRange(err.pos, err.pos + 1);
@@ -2033,13 +2123,16 @@ async function openJsoncEditor(initialFile) {
2033
2123
  const loadFile = async () => {
2034
2124
  textarea.value = "Loading...";
2035
2125
  clearValidation();
2126
+ renderGutter();
2036
2127
  try {
2037
2128
  const r = await readJsoncFile(fileSelect.value);
2038
2129
  textarea.value = r?.content || "";
2130
+ renderGutter();
2039
2131
  scheduleValidate();
2040
2132
  }
2041
2133
  catch (e) {
2042
2134
  textarea.value = "";
2135
+ renderGutter();
2043
2136
  errorEl.textContent = `Failed to load: ${e.message}`;
2044
2137
  errorEl.hidden = false;
2045
2138
  }
@@ -2739,6 +2832,19 @@ document.getElementById("status-queue")?.addEventListener("click", async () => {
2739
2832
  })();
2740
2833
  console.log("mailx client initialized, location:", location.href);
2741
2834
  updateNewMessageCount();
2835
+ // Offline indicator — show/hide based on navigator.onLine. Doesn't gate any
2836
+ // functionality (the store is local-first; edits queue and replay on
2837
+ // reconnect regardless) but tells the user their queued actions are stacking
2838
+ // up for a later push rather than hitting the server now.
2839
+ const offlineEl = document.getElementById("status-offline");
2840
+ function refreshOfflineIndicator() {
2841
+ if (!offlineEl)
2842
+ return;
2843
+ offlineEl.hidden = navigator.onLine;
2844
+ }
2845
+ window.addEventListener("online", refreshOfflineIndicator);
2846
+ window.addEventListener("offline", refreshOfflineIndicator);
2847
+ refreshOfflineIndicator();
2742
2848
  // ── Midnight refresh — update date display when day changes ──
2743
2849
  function scheduleMiddnightRefresh() {
2744
2850
  const now = new Date();
@@ -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, } from "../lib/api-client.js";
16
+ import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
17
17
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
18
18
  let viewYear = new Date().getFullYear();
19
19
  let viewMonth = new Date().getMonth();
@@ -105,6 +105,7 @@ async function renderTasks() {
105
105
  html += `<div class="cal-side-task" data-uuid="${t.uuid}">
106
106
  <input type="checkbox" ${done ? "checked" : ""} class="cal-side-task-check">
107
107
  <span class="cal-side-task-title${done ? " done" : ""}">${escapeHtml(t.title)}</span>
108
+ <button class="cal-side-task-delete" title="Delete task" aria-label="Delete task">×</button>
108
109
  </div>`;
109
110
  }
110
111
  host.innerHTML = html;
@@ -115,6 +116,10 @@ async function renderTasks() {
115
116
  await updateTask(uuid, { completedMs: checked ? Date.now() : null });
116
117
  renderTasks();
117
118
  });
119
+ row.querySelector(".cal-side-task-delete")?.addEventListener("click", async () => {
120
+ await deleteTask(uuid);
121
+ renderTasks();
122
+ });
118
123
  });
119
124
  }
120
125
  async function refresh() {
@@ -770,9 +770,34 @@ async function loadFolderTree(container) {
770
770
  const delimiter = folders[0]?.delimiter || ".";
771
771
  const tree = buildTree(folders, delimiter, account.id);
772
772
  sortFolders(tree);
773
+ // Case-duplicate detection: fold folder paths to lowercase and
774
+ // flag any whose form matches another. Common with servers that
775
+ // let users create `Archive` and `archive` as distinct folders,
776
+ // or `Sent Items` alongside a `Sent items` rename gone sideways.
777
+ // A ⚠ glyph on the affected rows lets the user notice before
778
+ // losing mail to the wrong one.
779
+ const lowerCounts = new Map();
780
+ for (const f of folders) {
781
+ const key = (f.path || "").toLowerCase();
782
+ lowerCounts.set(key, (lowerCounts.get(key) || 0) + 1);
783
+ }
784
+ const duplicatePaths = new Set();
785
+ for (const [k, c] of lowerCounts)
786
+ if (c > 1)
787
+ duplicatePaths.add(k);
773
788
  for (const node of tree) {
774
789
  renderNode(node, accountEl, 1);
775
790
  }
791
+ if (duplicatePaths.size > 0) {
792
+ accountEl.querySelectorAll(".ft-folder").forEach(el => {
793
+ const p = (el.dataset.folderPath || "").toLowerCase();
794
+ if (duplicatePaths.has(p)) {
795
+ el.classList.add("ft-folder-duplicate");
796
+ el.title = (el.title ? el.title + " — " : "") +
797
+ "Case-duplicate folder name on the server (another folder with the same name in different case exists)";
798
+ }
799
+ });
800
+ }
776
801
  }
777
802
  fragment.appendChild(accountEl);
778
803
  }
@@ -18,6 +18,12 @@ let searchMode = false;
18
18
  let currentSearchQuery = "";
19
19
  let showToInsteadOfFrom = false;
20
20
  let touchWasScroll = false;
21
+ // Current sort column/direction — cycled by clicking the ml-header columns.
22
+ // "date desc" is the default (newest first). Clicking a column flips direction
23
+ // if it's already active, or switches to that column with its own default dir
24
+ // (text columns default asc, date defaults desc).
25
+ let currentSort = "date";
26
+ let currentSortDir = "desc";
21
27
  /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
22
28
  * Called from the bodyCached service event — covers both background prefetch
23
29
  * and on-demand fetch. No-op for rows not currently rendered. */
@@ -119,6 +125,71 @@ export function initMessageList(handler) {
119
125
  syncDomToState();
120
126
  }
121
127
  });
128
+ // Sort column headers — click to cycle. Date defaults desc (newest first);
129
+ // From/Subject default asc on first click so alphabetical order reads
130
+ // naturally. Clicking the currently-active column flips direction.
131
+ const header = document.getElementById("ml-header");
132
+ if (header) {
133
+ header.addEventListener("click", (e) => {
134
+ const col = e.target.closest(".ml-col-sortable");
135
+ if (!col)
136
+ return;
137
+ const key = col.dataset.sort;
138
+ if (!key)
139
+ return;
140
+ if (currentSort === key) {
141
+ currentSortDir = currentSortDir === "asc" ? "desc" : "asc";
142
+ }
143
+ else {
144
+ currentSort = key;
145
+ currentSortDir = key === "date" ? "desc" : "asc";
146
+ }
147
+ // Only per-folder lists support server-side sort today; unified and
148
+ // search paths sort client-side on the fetched page. Reload if we
149
+ // have an active per-folder context.
150
+ if (!searchMode && !unifiedMode && currentAccountId && currentFolderId) {
151
+ loadMessages(currentAccountId, currentFolderId, 1, "", false);
152
+ }
153
+ else {
154
+ applyClientSideSort();
155
+ updateSortIndicators();
156
+ }
157
+ });
158
+ }
159
+ updateSortIndicators();
160
+ }
161
+ /** Reorder currently-loaded state messages in-place by currentSort/currentSortDir.
162
+ * Used for unified-inbox and search results where the server can't re-sort
163
+ * a single page on our behalf. */
164
+ function applyClientSideSort() {
165
+ const items = [...state.getMessages()];
166
+ const sign = currentSortDir === "asc" ? 1 : -1;
167
+ items.sort((a, b) => {
168
+ if (currentSort === "from") {
169
+ const av = (a.from?.name || a.from?.address || "").toLowerCase();
170
+ const bv = (b.from?.name || b.from?.address || "").toLowerCase();
171
+ return av < bv ? -sign : av > bv ? sign : 0;
172
+ }
173
+ if (currentSort === "subject") {
174
+ const av = (a.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
175
+ const bv = (b.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
176
+ return av < bv ? -sign : av > bv ? sign : 0;
177
+ }
178
+ // date
179
+ return ((a.date || 0) - (b.date || 0)) * sign;
180
+ });
181
+ state.setMessages(items);
182
+ }
183
+ function updateSortIndicators() {
184
+ const header = document.getElementById("ml-header");
185
+ if (!header)
186
+ return;
187
+ header.querySelectorAll(".ml-col-sortable").forEach(c => {
188
+ c.classList.remove("ml-col-sort-asc", "ml-col-sort-desc");
189
+ if (c.dataset.sort === currentSort) {
190
+ c.classList.add(currentSortDir === "asc" ? "ml-col-sort-asc" : "ml-col-sort-desc");
191
+ }
192
+ });
122
193
  }
123
194
  /**
124
195
  * Sync DOM rows to current state after messages are removed.
@@ -296,8 +367,9 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
296
367
  }
297
368
  try {
298
369
  const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
299
- const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly);
370
+ const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
300
371
  totalMessages = result.total;
372
+ updateSortIndicators();
301
373
  if (result.items.length === 0) {
302
374
  state.setMessages([]);
303
375
  body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
@@ -472,6 +544,8 @@ function appendMessages(body, accountId, items) {
472
544
  row.dataset.uid = String(msg.uid);
473
545
  row.dataset.accountId = msgAccountId;
474
546
  row.dataset.folderId = String(msg.folderId);
547
+ if (msg.threadId)
548
+ row.dataset.threadId = msg.threadId;
475
549
  const flag = document.createElement("span");
476
550
  flag.className = "ml-flag";
477
551
  flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
@@ -536,6 +536,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
536
536
  (returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
537
537
  `</div>` +
538
538
  (deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
539
+ `<div class="mv-rb-actions"><button id="btn-edit-allowlist" title="View / edit the full allowlist">Edit allowlist…</button></div>` +
539
540
  `</div>`;
540
541
  bodyEl.appendChild(banner);
541
542
  // Toggle dropdown — click arrow or text to expand details
@@ -577,6 +578,12 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
577
578
  await allowRemoteContent("recipient", addr);
578
579
  loadRemote();
579
580
  });
581
+ // "Edit allowlist…" — fires a document-level event that app.ts
582
+ // listens for and opens the JSONC editor pre-selected to
583
+ // allowlist.jsonc. Keeps message-viewer free of the editor import.
584
+ banner.querySelector("#btn-edit-allowlist")?.addEventListener("click", () => {
585
+ document.dispatchEvent(new CustomEvent("mailx-open-jsonc-editor", { detail: { file: "allowlist.jsonc" } }));
586
+ });
580
587
  }
581
588
  // Body fetch error — show banner above (empty) body instead of polluting
582
589
  // the main content area with the error text. Transient errors get a retry.
package/client/index.html CHANGED
@@ -26,6 +26,7 @@
26
26
  <label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
27
27
  <label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
28
28
  <label class="tb-menu-item"><input type="checkbox" id="opt-threaded"> Group by thread</label>
29
+ <label class="tb-menu-item"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
29
30
  <label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
30
31
  <label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
31
32
  <label class="tb-menu-item"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
@@ -110,11 +111,11 @@
110
111
  <label class="search-server-check" title="Also search the IMAP server (slower; spans all folders on all accounts)"><input type="checkbox" id="search-server-too"> Server</label>
111
112
  </search>
112
113
  <div class="ml-folder-title" id="ml-folder-title"></div>
113
- <div class="ml-header">
114
+ <div class="ml-header" id="ml-header">
114
115
  <span class="ml-col ml-col-flag"></span>
115
- <span class="ml-col ml-col-from" data-sort="from">From</span>
116
- <span class="ml-col ml-col-date" data-sort="date">Date</span>
117
- <span class="ml-col ml-col-subject">Subject</span>
116
+ <span class="ml-col ml-col-from ml-col-sortable" data-sort="from">From</span>
117
+ <span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
118
+ <span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
118
119
  </div>
119
120
  <div class="ml-body" id="ml-body">
120
121
  <div class="ml-empty">Select a folder to view messages</div>
@@ -181,6 +182,7 @@
181
182
  <footer class="status-bar" id="status-bar">
182
183
  <span id="status-accounts"></span>
183
184
  <span id="status-sync">Syncing...</span>
185
+ <span id="status-offline" class="status-offline" hidden title="No network — local actions queue for later">⚡ offline</span>
184
186
  <span id="status-diag" class="status-diag" hidden title=""></span>
185
187
  <span id="status-pending"></span>
186
188
  <span id="status-queue"></span>
@@ -102,9 +102,9 @@ export function getAccounts() {
102
102
  export function getFolders(accountId) {
103
103
  return ipc().getFolders(accountId);
104
104
  }
105
- export function getMessages(accountId, folderId, page = 1, pageSize = 50, flaggedOnly = false) {
105
+ export function getMessages(accountId, folderId, page = 1, pageSize = 50, flaggedOnly = false, sort, sortDir) {
106
106
  abortMessageListRequests();
107
- return ipc().getMessages(accountId, folderId, page, pageSize, undefined, undefined, undefined, flaggedOnly);
107
+ return ipc().getMessages(accountId, folderId, page, pageSize, sort, sortDir, undefined, flaggedOnly);
108
108
  }
109
109
  export function getUnifiedInbox(page = 1, pageSize = 50) {
110
110
  abortMessageListRequests();
@@ -205,6 +205,15 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
205
205
 
206
206
  .ft-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
207
207
 
208
+ /* Case-duplicate warning — another folder exists with the same name in a
209
+ different case on the server. Hover for the full explanation via title. */
210
+ .ft-folder-duplicate .ft-folder-name::after {
211
+ content: " ⚠";
212
+ color: oklch(0.65 0.2 65);
213
+ font-weight: 600;
214
+ margin-left: 4px;
215
+ }
216
+
208
217
  .ft-badge {
209
218
  font-size: 0.7rem;
210
219
  padding: 0.1rem 0.4rem;
@@ -332,6 +341,18 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
332
341
  user-select: none;
333
342
 
334
343
  .ml-col { cursor: pointer; &:hover { color: var(--color-text); } }
344
+ .ml-col-sortable { position: relative; padding-right: 14px; }
345
+ .ml-col-sort-asc::after,
346
+ .ml-col-sort-desc::after {
347
+ position: absolute;
348
+ right: 2px;
349
+ top: 50%;
350
+ transform: translateY(-50%);
351
+ font-size: 10px;
352
+ color: var(--color-accent);
353
+ }
354
+ .ml-col-sort-asc::after { content: "▲"; }
355
+ .ml-col-sort-desc::after { content: "▼"; }
335
356
  }
336
357
 
337
358
  /* Narrow-mode folder title above the list — hidden on wide where the window
@@ -603,6 +624,64 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
603
624
  white-space: pre;
604
625
  tab-size: 2;
605
626
  }
627
+
628
+ /* JSONC editor with line-number gutter. Gutter + textarea share the same
629
+ line-height so numbers stay aligned; textarea owns the scroll and the
630
+ gutter syncs via JS. Error line is highlighted red in the gutter so the
631
+ "Line N, col M" error points at a visible marker. */
632
+ .jsonc-editor-wrap {
633
+ flex: 1;
634
+ display: flex;
635
+ min-height: 200px;
636
+ border: 1px solid var(--color-border);
637
+ border-radius: var(--radius-sm);
638
+ overflow: hidden;
639
+ background: var(--color-bg-surface);
640
+ }
641
+ .jsonc-gutter {
642
+ flex: 0 0 auto;
643
+ min-width: 3em;
644
+ padding: 6px 6px 6px 10px;
645
+ background: oklch(0.94 0.005 250);
646
+ border-right: 1px solid var(--color-border);
647
+ color: var(--color-text-muted);
648
+ font-family: var(--font-mono);
649
+ font-size: 13px;
650
+ line-height: 1.5;
651
+ text-align: right;
652
+ user-select: none;
653
+ overflow: hidden;
654
+ white-space: pre;
655
+ tab-size: 2;
656
+ }
657
+ .jsonc-gutter-line {
658
+ line-height: 1.5;
659
+ font-variant-numeric: tabular-nums;
660
+ }
661
+ .jsonc-gutter-error {
662
+ background: oklch(0.65 0.2 25);
663
+ color: #fff;
664
+ font-weight: 600;
665
+ border-radius: 2px;
666
+ padding: 0 4px;
667
+ margin: 0 -4px;
668
+ }
669
+ .jsonc-editor-wrap .jsonc-textarea {
670
+ flex: 1;
671
+ border: 0;
672
+ border-radius: 0;
673
+ line-height: 1.5;
674
+ resize: none;
675
+ /* The outer wrap draws the focus ring so gutter + area move together */
676
+ }
677
+ .jsonc-editor-wrap:focus-within {
678
+ outline: 2px solid var(--color-accent);
679
+ outline-offset: -1px;
680
+ }
681
+ .jsonc-editor-wrap:has(.mailx-modal-input-error) {
682
+ outline: 2px solid oklch(0.65 0.2 25);
683
+ outline-offset: -1px;
684
+ }
606
685
  .mailx-modal-input:focus {
607
686
  outline: 2px solid var(--color-accent);
608
687
  outline-offset: -1px;
@@ -816,6 +895,21 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
816
895
  opacity: 0.9;
817
896
  }
818
897
 
898
+ /* Filter: only this conversation — rows not in the current thread hide.
899
+ The selected row stays visible regardless so toggling doesn't leave the
900
+ list empty when the selection is a singleton thread. */
901
+ .ml-row.thread-filter-hidden { display: none; }
902
+
903
+ /* Offline indicator — sits in the status bar; amber tone so it's visible
904
+ but doesn't scream (being offline is normal local-first behavior, not
905
+ an error). */
906
+ .status-offline {
907
+ color: oklch(0.65 0.18 65);
908
+ font-weight: 600;
909
+ font-size: var(--font-size-sm);
910
+ padding: 0 6px;
911
+ }
912
+
819
913
  /* S51 — calendar sidebar (Thunderbird Lightning Events & Tasks pane).
820
914
  Right-docked, fixed width. Visible by default; user hides via View menu.
821
915
  Hides automatically on narrow screens (< 1100px) — Android uses the
@@ -926,11 +1020,41 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
926
1020
  align-items: center;
927
1021
  gap: 6px;
928
1022
  padding: 2px 0;
1023
+
1024
+ .cal-side-task-delete {
1025
+ margin-left: auto;
1026
+ opacity: 0;
1027
+ background: transparent;
1028
+ border: 0;
1029
+ cursor: pointer;
1030
+ color: var(--color-text-muted);
1031
+ padding: 0 4px;
1032
+ font-size: 14px;
1033
+ line-height: 1;
1034
+ transition: opacity 0.15s, color 0.15s;
1035
+ }
1036
+ &:hover .cal-side-task-delete { opacity: 1; }
1037
+ .cal-side-task-delete:hover { color: oklch(0.55 0.22 25); }
1038
+ }
1039
+ .cal-side-task-title {
1040
+ flex: 1;
1041
+ overflow: hidden;
1042
+ text-overflow: ellipsis;
1043
+ white-space: nowrap;
929
1044
  }
930
1045
  .cal-side-task-title.done {
931
1046
  text-decoration: line-through;
932
1047
  color: var(--color-text-muted);
933
1048
  }
1049
+ .cal-side-task-header-row {
1050
+ display: flex;
1051
+ align-items: center;
1052
+ gap: var(--gap-xs);
1053
+ padding: 2px 0;
1054
+
1055
+ label { flex: 1; }
1056
+ .cal-side-new { width: auto; padding: 2px 8px; font-size: 0.85em; }
1057
+ }
934
1058
 
935
1059
  .ml-empty {
936
1060
  grid-column: 1 / -1;
@@ -89,6 +89,23 @@ body.calendar-sidebar-on {
89
89
  .rail-btn[disabled] {
90
90
  color: oklch(0.55 0.01 250);
91
91
  }
92
+ .rail-btn { position: relative; }
93
+ .rail-badge {
94
+ position: absolute;
95
+ top: 3px;
96
+ right: 6px;
97
+ min-width: 18px;
98
+ padding: 1px 4px;
99
+ border-radius: 9px;
100
+ background: oklch(0.65 0.22 25);
101
+ color: #fff;
102
+ font-size: 10px;
103
+ font-weight: 600;
104
+ line-height: 1.4;
105
+ text-align: center;
106
+ font-variant-numeric: tabular-nums;
107
+ pointer-events: none;
108
+ }
92
109
  .rail-btn[disabled] {
93
110
  opacity: 0.35;
94
111
  cursor: not-allowed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.379",
3
+ "version": "1.0.383",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
25
  "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.345",
27
+ "@bobfrankston/msger": "^0.1.347",
28
28
  "@bobfrankston/mailx-host": "^0.1.4",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
@@ -39,7 +39,7 @@
39
39
  "@bobfrankston/tcp-transport": "^0.1.4",
40
40
  "@bobfrankston/node-tcp-transport": "^0.1.4",
41
41
  "@bobfrankston/smtp-direct": "^0.1.4",
42
- "@bobfrankston/mailx-sync": "^0.1.8"
42
+ "@bobfrankston/mailx-sync": "^0.1.9"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/mailparser": "^3.4.6"
@@ -88,7 +88,7 @@
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
89
  "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.345",
91
+ "@bobfrankston/msger": "^0.1.347",
92
92
  "@bobfrankston/mailx-host": "^0.1.4",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
@@ -103,7 +103,7 @@
103
103
  "@bobfrankston/tcp-transport": "^0.1.4",
104
104
  "@bobfrankston/node-tcp-transport": "^0.1.4",
105
105
  "@bobfrankston/smtp-direct": "^0.1.4",
106
- "@bobfrankston/mailx-sync": "^0.1.8"
106
+ "@bobfrankston/mailx-sync": "^0.1.9"
107
107
  }
108
108
  }
109
109
  }
@@ -227,6 +227,20 @@ export declare class ImapManager extends EventEmitter {
227
227
  * prefetch session alongside any still in flight, blowing through Gmail's
228
228
  * per-minute quota and racing on disk writes. One prefetch per account. */
229
229
  private prefetchingAccounts;
230
+ /** Per-folder error cooldowns — `accountId:folderPath` → [timestamps].
231
+ * Used to skip folders that repeatedly time out (Dovecot on slow shared
232
+ * hosting SELECTs big `_Spam` / archive folders at 300s+ latency; the
233
+ * prefetch error budget was burning out on a handful of bad folders
234
+ * before the INBOX could finish). A folder with 2+ errors in the last
235
+ * 15 minutes is skipped until the cooldown passes. User-reported via
236
+ * log analysis 2026-04-23: bobma prefetch timing out on Added2.organizations,
237
+ * Added2.technews, _Spam, "Prefirst.Jerry's Retreat", Added2.zines. */
238
+ private folderErrorCooldown;
239
+ private readonly FOLDER_ERROR_WINDOW_MS;
240
+ private readonly FOLDER_ERROR_THRESHOLD;
241
+ private shouldSkipFolder;
242
+ private recordFolderError;
243
+ private clearFolderErrors;
230
244
  private prefetchBodies;
231
245
  private _prefetchBodies;
232
246
  /** Get the body store for direct access */
@@ -1925,6 +1925,34 @@ export class ImapManager extends EventEmitter {
1925
1925
  * prefetch session alongside any still in flight, blowing through Gmail's
1926
1926
  * per-minute quota and racing on disk writes. One prefetch per account. */
1927
1927
  prefetchingAccounts = new Set();
1928
+ /** Per-folder error cooldowns — `accountId:folderPath` → [timestamps].
1929
+ * Used to skip folders that repeatedly time out (Dovecot on slow shared
1930
+ * hosting SELECTs big `_Spam` / archive folders at 300s+ latency; the
1931
+ * prefetch error budget was burning out on a handful of bad folders
1932
+ * before the INBOX could finish). A folder with 2+ errors in the last
1933
+ * 15 minutes is skipped until the cooldown passes. User-reported via
1934
+ * log analysis 2026-04-23: bobma prefetch timing out on Added2.organizations,
1935
+ * Added2.technews, _Spam, "Prefirst.Jerry's Retreat", Added2.zines. */
1936
+ folderErrorCooldown = new Map();
1937
+ FOLDER_ERROR_WINDOW_MS = 15 * 60_000;
1938
+ FOLDER_ERROR_THRESHOLD = 2;
1939
+ shouldSkipFolder(accountId, folderPath) {
1940
+ const key = `${accountId}:${folderPath}`;
1941
+ const now = Date.now();
1942
+ const errors = (this.folderErrorCooldown.get(key) || [])
1943
+ .filter(t => now - t < this.FOLDER_ERROR_WINDOW_MS);
1944
+ this.folderErrorCooldown.set(key, errors);
1945
+ return errors.length >= this.FOLDER_ERROR_THRESHOLD;
1946
+ }
1947
+ recordFolderError(accountId, folderPath) {
1948
+ const key = `${accountId}:${folderPath}`;
1949
+ const arr = this.folderErrorCooldown.get(key) || [];
1950
+ arr.push(Date.now());
1951
+ this.folderErrorCooldown.set(key, arr);
1952
+ }
1953
+ clearFolderErrors(accountId, folderPath) {
1954
+ this.folderErrorCooldown.delete(`${accountId}:${folderPath}`);
1955
+ }
1928
1956
  async prefetchBodies(accountId) {
1929
1957
  if (this.prefetchingAccounts.has(accountId))
1930
1958
  return;
@@ -2061,10 +2089,26 @@ export class ImapManager extends EventEmitter {
2061
2089
  let client = null;
2062
2090
  try {
2063
2091
  client = await this.createClientWithLimit(accountId);
2064
- for (const [folderId, uids] of byFolder) {
2092
+ // INBOX-first ordering so the folder the user actually looks at
2093
+ // gets its bodies even if a later folder eats the error budget.
2094
+ const orderedFolders = Array.from(byFolder.entries()).sort(([aid], [bid]) => {
2095
+ const af = folders.find(f => f.id === aid);
2096
+ const bf = folders.find(f => f.id === bid);
2097
+ const ai = af?.specialUse === "inbox" ? 0 : 1;
2098
+ const bi = bf?.specialUse === "inbox" ? 0 : 1;
2099
+ return ai - bi;
2100
+ });
2101
+ for (const [folderId, uids] of orderedFolders) {
2065
2102
  const folder = folders.find(f => f.id === folderId);
2066
2103
  if (!folder)
2067
2104
  continue;
2105
+ // Skip folders that have repeatedly timed out — keeps one
2106
+ // slow folder from burning the whole error budget and
2107
+ // starving the folder the user is actually looking at.
2108
+ if (this.shouldSkipFolder(accountId, folder.path)) {
2109
+ console.log(` [prefetch] ${accountId}: skipping ${folder.path} (recent timeouts — cooling down)`);
2110
+ continue;
2111
+ }
2068
2112
  const received = new Set();
2069
2113
  // onBody fires synchronously as each message streams in from the server.
2070
2114
  // Disk/DB writes are kicked off fire-and-forget; we await them after the
@@ -2091,10 +2135,13 @@ export class ImapManager extends EventEmitter {
2091
2135
  })());
2092
2136
  });
2093
2137
  batchSucceeded = true;
2138
+ // Folder responded — clear its error history.
2139
+ this.clearFolderErrors(accountId, folder.path);
2094
2140
  }
2095
2141
  catch (e) {
2096
2142
  console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${e.message}`);
2097
2143
  counters.errors++;
2144
+ this.recordFolderError(accountId, folder.path);
2098
2145
  if (counters.errors >= ERROR_BUDGET)
2099
2146
  break;
2100
2147
  }
@@ -2299,8 +2346,33 @@ export class ImapManager extends EventEmitter {
2299
2346
  await api.setFlags(folder.path, action.uid, action.flags || []);
2300
2347
  console.log(` [api] ${accountId}: flags synced UID ${action.uid}`);
2301
2348
  }
2349
+ else if ((action.action === "delete" || action.action === "trash") && api.trashMessage) {
2350
+ await api.trashMessage(folder.path, action.uid);
2351
+ console.log(` [api] ${accountId}: trashed UID ${action.uid} from ${folder.path}`);
2352
+ }
2353
+ else if (action.action === "move" && api.moveMessage) {
2354
+ const target = folders.find(f => f.id === action.targetFolderId);
2355
+ if (!target) {
2356
+ // Unreachable target — drop the action rather than loop.
2357
+ console.error(` [api] ${accountId}: move target folder ${action.targetFolderId} missing — dropping UID ${action.uid}`);
2358
+ this.db.completeSyncAction(action.id);
2359
+ continue;
2360
+ }
2361
+ await api.moveMessage(folder.path, action.uid, target.path);
2362
+ console.log(` [api] ${accountId}: moved UID ${action.uid} ${folder.path} → ${target.path}`);
2363
+ }
2302
2364
  else {
2303
- // move/delete/append via API not implemented yet leave queued
2365
+ // Unsupported action on Gmail. After 5 retries, drop it
2366
+ // so stale rows don't mark messages pending-reconcile
2367
+ // forever. Previously "continue" here caused the pink
2368
+ // rows that shouldn't have been pink.
2369
+ if (action.attempts >= 5) {
2370
+ console.warn(` [api] ${accountId}: dropping stale action "${action.action}" UID ${action.uid} after ${action.attempts} attempts (unsupported on Gmail API path)`);
2371
+ this.db.completeSyncAction(action.id);
2372
+ }
2373
+ else {
2374
+ this.db.failSyncAction(action.id, `unsupported Gmail action: ${action.action}`);
2375
+ }
2304
2376
  continue;
2305
2377
  }
2306
2378
  this.db.completeSyncAction(action.id);
@@ -498,7 +498,7 @@ export class MailxService {
498
498
  async updateCalendarEventLocal(uuid, patch) {
499
499
  // Merge with existing row before writing so partial patches don't
500
500
  // null-out unspecified fields in upsert.
501
- const existing = this.db.getCalendarEvents("", 0, Number.MAX_SAFE_INTEGER).find(e => e.uuid === uuid);
501
+ const existing = this.db.getCalendarEventByUuid(uuid);
502
502
  if (!existing)
503
503
  throw new Error(`No calendar event ${uuid}`);
504
504
  this.db.upsertCalendarEvent({
@@ -515,7 +515,7 @@ export class MailxService {
515
515
  this.drainStoreSync().catch(() => { });
516
516
  }
517
517
  async deleteCalendarEventLocal(uuid) {
518
- const ev = this.db.getCalendarEvents("", 0, Number.MAX_SAFE_INTEGER).find(e => e.uuid === uuid);
518
+ const ev = this.db.getCalendarEventByUuid(uuid);
519
519
  if (!ev)
520
520
  return;
521
521
  this.db.deleteCalendarEventLocal(uuid);
@@ -555,7 +555,7 @@ export class MailxService {
555
555
  return uuid;
556
556
  }
557
557
  async updateTaskLocal(uuid, patch) {
558
- const existing = this.db.getTasks("", true).find(t => t.uuid === uuid);
558
+ const existing = this.db.getTaskByUuid(uuid);
559
559
  if (!existing)
560
560
  throw new Error(`No task ${uuid}`);
561
561
  this.db.upsertTask({
@@ -570,7 +570,7 @@ export class MailxService {
570
570
  this.drainStoreSync().catch(() => { });
571
571
  }
572
572
  async deleteTaskLocal(uuid) {
573
- const task = this.db.getTasks("", true).find(t => t.uuid === uuid);
573
+ const task = this.db.getTaskByUuid(uuid);
574
574
  if (!task)
575
575
  return;
576
576
  this.db.deleteTaskLocal(uuid);
@@ -48,6 +48,10 @@ export declare class MailxDB {
48
48
  dirty?: boolean;
49
49
  }): string;
50
50
  getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
51
+ /** Lookup by uuid only — used by patch/delete paths that don't have an
52
+ * accountId context. Returns the row even when it's soft-deleted. */
53
+ getCalendarEventByUuid(uuid: string): any | null;
54
+ getTaskByUuid(uuid: string): any | null;
51
55
  getDirtyCalendarEvents(accountId: string): any[];
52
56
  private calendarRowToObject;
53
57
  markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
@@ -396,6 +396,16 @@ export class MailxDB {
396
396
  `).all(accountId, fromMs, toMs);
397
397
  return rows.map(this.calendarRowToObject);
398
398
  }
399
+ /** Lookup by uuid only — used by patch/delete paths that don't have an
400
+ * accountId context. Returns the row even when it's soft-deleted. */
401
+ getCalendarEventByUuid(uuid) {
402
+ const r = this.db.prepare("SELECT * FROM calendar_events WHERE uuid = ?").get(uuid);
403
+ return r ? this.calendarRowToObject(r) : null;
404
+ }
405
+ getTaskByUuid(uuid) {
406
+ const r = this.db.prepare("SELECT * FROM tasks WHERE uuid = ?").get(uuid);
407
+ return r ? this.taskRowToObject(r) : null;
408
+ }
399
409
  getDirtyCalendarEvents(accountId) {
400
410
  const rows = this.db.prepare(`
401
411
  SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
@@ -997,11 +1007,34 @@ export class MailxDB {
997
1007
  }
998
1008
  /** Search contacts by name or email prefix */
999
1009
  searchContacts(query, limit = 10) {
1000
- const q = `%${query}%`;
1001
- const rows = this.db.prepare(`SELECT name, email, source, use_count FROM contacts
1010
+ // Two-pass ranking so autocomplete feels responsive: rows whose name or
1011
+ // local-part starts with the query rank first (exact prefix match is
1012
+ // usually what the user wants), then substring matches fill out the
1013
+ // rest. Within each tier, sort by (recency-weighted use_count) so a
1014
+ // contact the user messaged today beats one from two years ago even
1015
+ // if the older one has more total sends. Recency bonus: +1 per send
1016
+ // decayed by ~half every 30 days.
1017
+ const prefixQ = `${query}%`;
1018
+ const substr = `%${query}%`;
1019
+ const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1020
+ (CASE
1021
+ WHEN lower(name) LIKE lower(?) THEN 3
1022
+ WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1023
+ WHEN email LIKE ? OR name LIKE ? THEN 1
1024
+ ELSE 0
1025
+ END) AS match_rank
1026
+ FROM contacts
1002
1027
  WHERE email LIKE ? OR name LIKE ?
1003
- ORDER BY use_count DESC, last_used DESC
1004
- LIMIT ?`).all(q, q, limit);
1028
+ ORDER BY match_rank DESC, use_count DESC, last_used DESC
1029
+ LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
1030
+ // Recency-weighted rescore, best-in-JS since SQLite lacks log/exp
1031
+ // natively. 30-day half-life — close enough to "recent contacts
1032
+ // float up" without being fussy about the exact decay curve.
1033
+ const now = Date.now();
1034
+ const HALF_LIFE_MS = 30 * 86400_000;
1035
+ const score = (r) => r.match_rank * 10_000
1036
+ + (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
1037
+ rows.sort((a, b) => score(b) - score(a));
1005
1038
  return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
1006
1039
  }
1007
1040
  /** List all contacts (address-book view) with pagination + optional filter. */
@@ -109,6 +109,60 @@ const SCHEMA = `
109
109
  last_error TEXT,
110
110
  UNIQUE(account_id, action, uid, folder_id)
111
111
  );
112
+
113
+ -- Calendar events two-way cache (Android parity with desktop's
114
+ -- packages/mailx-store/db.ts calendar_events table). uuid = stable
115
+ -- local identity, provider_id = Google Calendar event id when known,
116
+ -- dirty = local edit not yet pushed, deleted = tombstone pending delete.
117
+ CREATE TABLE IF NOT EXISTS calendar_events (
118
+ uuid TEXT PRIMARY KEY,
119
+ account_id TEXT NOT NULL,
120
+ provider_id TEXT,
121
+ calendar_id TEXT DEFAULT 'primary',
122
+ title TEXT NOT NULL DEFAULT '',
123
+ start_ms INTEGER NOT NULL,
124
+ end_ms INTEGER NOT NULL,
125
+ all_day INTEGER DEFAULT 0,
126
+ location TEXT DEFAULT '',
127
+ notes TEXT DEFAULT '',
128
+ etag TEXT,
129
+ last_synced INTEGER DEFAULT 0,
130
+ dirty INTEGER DEFAULT 0,
131
+ deleted INTEGER DEFAULT 0,
132
+ updated_at INTEGER NOT NULL
133
+ );
134
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
135
+
136
+ CREATE TABLE IF NOT EXISTS tasks (
137
+ uuid TEXT PRIMARY KEY,
138
+ account_id TEXT NOT NULL,
139
+ provider_id TEXT,
140
+ list_id TEXT DEFAULT '@default',
141
+ title TEXT NOT NULL DEFAULT '',
142
+ notes TEXT DEFAULT '',
143
+ due_ms INTEGER,
144
+ completed_ms INTEGER,
145
+ etag TEXT,
146
+ last_synced INTEGER DEFAULT 0,
147
+ dirty INTEGER DEFAULT 0,
148
+ deleted INTEGER DEFAULT 0,
149
+ updated_at INTEGER NOT NULL
150
+ );
151
+ CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
152
+
153
+ -- Generic store-sync queue for non-message domains (calendar/tasks/contacts).
154
+ CREATE TABLE IF NOT EXISTS store_sync (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ kind TEXT NOT NULL,
157
+ op TEXT NOT NULL,
158
+ account_id TEXT NOT NULL,
159
+ target_uuid TEXT NOT NULL,
160
+ payload TEXT,
161
+ attempts INTEGER DEFAULT 0,
162
+ last_error TEXT,
163
+ created_at INTEGER NOT NULL,
164
+ UNIQUE(kind, target_uuid, op)
165
+ );
112
166
  `;
113
167
  const IDB_NAME = "mailx-sqldb";
114
168
  const IDB_STORE = "database";
@@ -311,21 +311,30 @@ export class WebMailxService {
311
311
  }
312
312
  async syncAccount(accountId) {
313
313
  const folders = await this.syncManager.syncFolders(accountId);
314
- const sorted = [...folders].sort((a, b) => {
315
- if (a.specialUse === "inbox")
316
- return -1;
317
- if (b.specialUse === "inbox")
318
- return 1;
319
- return 0;
320
- });
321
- for (const folder of sorted) {
314
+ // INBOX-first: await INBOX so the UI re-renders new mail immediately,
315
+ // then fire the rest in the background so labels don't block the
316
+ // list. S57 (Android parity with desktop's local-first rule).
317
+ const inbox = folders.find(f => f.specialUse === "inbox");
318
+ const others = folders.filter(f => f.specialUse !== "inbox");
319
+ if (inbox) {
322
320
  try {
323
- await this.syncManager.syncFolder(accountId, folder.id);
321
+ await this.syncManager.syncFolder(accountId, inbox.id);
324
322
  }
325
323
  catch (e) {
326
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
324
+ console.error(` Skipping INBOX ${inbox.path}: ${e.message}`);
327
325
  }
328
326
  }
327
+ // Background fan-out: don't await; errors log. UI already has INBOX.
328
+ (async () => {
329
+ for (const folder of others) {
330
+ try {
331
+ await this.syncManager.syncFolder(accountId, folder.id);
332
+ }
333
+ catch (e) {
334
+ console.error(` Skipping folder ${folder.path}: ${e.message}`);
335
+ }
336
+ }
337
+ })().catch(() => { });
329
338
  }
330
339
  async reauthenticate(accountId) {
331
340
  return this.syncManager.reauthenticate(accountId);
@@ -278,10 +278,22 @@ export declare function sanitizeHtml(html: string): {
278
278
  /** Encode text as RFC 2045 quoted-printable. */
279
279
  export declare function encodeQuotedPrintable(text: string): string;
280
280
  /** Parse search query into structured conditions.
281
- * Supports qualifiers: from:, to:, subject: (unqualified terms search everything).
282
- * Returns { conditions, params } for SQL WHERE clause with LIKE. */
281
+ * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
282
+ * is:flagged, is:unread, is:read. Unqualified terms search across subject /
283
+ * from / preview. Returns { conditions, params } for SQL WHERE clause with
284
+ * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
285
+ * range comparisons).
286
+ *
287
+ * Date syntax (matches Gmail-ish conventions):
288
+ * - date:2026-04-22 exact day
289
+ * - date:2026-04 month
290
+ * - date:>2026-04-01 after
291
+ * - date:<2026-04-01 before
292
+ * - date:2026-04-01..2026-04-30 range
293
+ * - date:today / yesterday / last7 / last30
294
+ */
283
295
  export declare function parseSearchQuery(query: string): {
284
296
  conditions: string[];
285
- params: string[];
297
+ params: (string | number)[];
286
298
  };
287
299
  //# sourceMappingURL=index.d.ts.map
@@ -66,16 +66,68 @@ export function encodeQuotedPrintable(text) {
66
66
  return result;
67
67
  }
68
68
  /** Parse search query into structured conditions.
69
- * Supports qualifiers: from:, to:, subject: (unqualified terms search everything).
70
- * Returns { conditions, params } for SQL WHERE clause with LIKE. */
69
+ * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
70
+ * is:flagged, is:unread, is:read. Unqualified terms search across subject /
71
+ * from / preview. Returns { conditions, params } for SQL WHERE clause with
72
+ * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
73
+ * range comparisons).
74
+ *
75
+ * Date syntax (matches Gmail-ish conventions):
76
+ * - date:2026-04-22 exact day
77
+ * - date:2026-04 month
78
+ * - date:>2026-04-01 after
79
+ * - date:<2026-04-01 before
80
+ * - date:2026-04-01..2026-04-30 range
81
+ * - date:today / yesterday / last7 / last30
82
+ */
71
83
  export function parseSearchQuery(query) {
72
84
  const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
73
85
  const conditions = [];
74
86
  const params = [];
87
+ const dayStart = (y, m, d) => new Date(y, m - 1, d).getTime();
88
+ const parseDateSpec = (spec) => {
89
+ const now = new Date();
90
+ const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
91
+ if (spec === "today")
92
+ return { from: today0, to: today0 + 86400_000 };
93
+ if (spec === "yesterday")
94
+ return { from: today0 - 86400_000, to: today0 };
95
+ const lastN = spec.match(/^last(\d+)$/i);
96
+ if (lastN)
97
+ return { from: today0 - parseInt(lastN[1]) * 86400_000 };
98
+ const rangeMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})\.\.(\d{4})-(\d{2})-(\d{2})$/);
99
+ if (rangeMatch)
100
+ return {
101
+ from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),
102
+ to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,
103
+ };
104
+ const gtMatch = spec.match(/^>(\d{4})-(\d{2})-(\d{2})$/);
105
+ if (gtMatch)
106
+ return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };
107
+ const ltMatch = spec.match(/^<(\d{4})-(\d{2})-(\d{2})$/);
108
+ if (ltMatch)
109
+ return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };
110
+ const monthMatch = spec.match(/^(\d{4})-(\d{2})$/);
111
+ if (monthMatch) {
112
+ const y = +monthMatch[1], m = +monthMatch[2];
113
+ const from = dayStart(y, m, 1);
114
+ const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);
115
+ return { from, to };
116
+ }
117
+ const dayMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})$/);
118
+ if (dayMatch) {
119
+ const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);
120
+ return { from, to: from + 86400_000 };
121
+ }
122
+ return null;
123
+ };
75
124
  for (const part of parts) {
76
125
  const fromMatch = part.match(/^from:(.+)$/i);
77
126
  const toMatch = part.match(/^to:(.+)$/i);
78
127
  const subjectMatch = part.match(/^subject:(.+)$/i);
128
+ const hasMatch = part.match(/^has:(.+)$/i);
129
+ const isMatch = part.match(/^is:(.+)$/i);
130
+ const dateMatch = part.match(/^date:(.+)$/i);
79
131
  if (fromMatch) {
80
132
  const term = `%${fromMatch[1].replace(/"/g, "")}%`;
81
133
  conditions.push("(from_name LIKE ? OR from_address LIKE ?)");
@@ -91,6 +143,42 @@ export function parseSearchQuery(query) {
91
143
  conditions.push("subject LIKE ?");
92
144
  params.push(term);
93
145
  }
146
+ else if (hasMatch) {
147
+ const v = hasMatch[1].toLowerCase();
148
+ if (v === "attachment" || v === "attachments") {
149
+ conditions.push("has_attachments = 1");
150
+ }
151
+ // Unknown has: qualifier — silently drop; treating as a literal
152
+ // search term would be confusing.
153
+ }
154
+ else if (isMatch) {
155
+ const v = isMatch[1].toLowerCase();
156
+ if (v === "flagged" || v === "starred") {
157
+ conditions.push("flags_json LIKE ?");
158
+ params.push("%\\\\Flagged%");
159
+ }
160
+ else if (v === "unread") {
161
+ conditions.push("flags_json NOT LIKE ?");
162
+ params.push("%\\\\Seen%");
163
+ }
164
+ else if (v === "read") {
165
+ conditions.push("flags_json LIKE ?");
166
+ params.push("%\\\\Seen%");
167
+ }
168
+ }
169
+ else if (dateMatch) {
170
+ const spec = parseDateSpec(dateMatch[1]);
171
+ if (spec) {
172
+ if (spec.from !== undefined) {
173
+ conditions.push("date >= ?");
174
+ params.push(spec.from);
175
+ }
176
+ if (spec.to !== undefined) {
177
+ conditions.push("date < ?");
178
+ params.push(spec.to);
179
+ }
180
+ }
181
+ }
94
182
  else {
95
183
  const term = `%${part}%`;
96
184
  conditions.push("(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)");