@bobfrankston/mailx 1.0.378 → 1.0.382

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();
@@ -10,68 +10,33 @@
10
10
  * Sidebar and the full-screen calendar modal (calendar.ts) read the SAME
11
11
  * underlying data — two views onto one source.
12
12
  *
13
- * Local-only events (LOCAL_STORE_KEY) are merged with Google events.
13
+ * All storage goes through the service-side two-way cache (calendar_events
14
+ * and tasks tables); this file does not use localStorage for data.
14
15
  */
15
- import { getPrimaryAccount } from "../lib/api-client.js";
16
- const LOCAL_STORE_KEY = "mailx-cal-events-v1";
17
- const TASK_STORE_KEY = "mailx-tasks-v1";
16
+ import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
18
17
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
19
18
  let viewYear = new Date().getFullYear();
20
19
  let viewMonth = new Date().getMonth();
21
20
  let viewDay = new Date().getDate();
22
21
  let lastEvents = [];
23
- function loadLocalEvents() {
24
- try {
25
- const raw = localStorage.getItem(LOCAL_STORE_KEY);
26
- if (!raw)
27
- return [];
28
- const arr = JSON.parse(raw);
29
- return Array.isArray(arr) ? arr : [];
30
- }
31
- catch {
32
- return [];
33
- }
34
- }
35
- function loadTasks() {
36
- try {
37
- const raw = localStorage.getItem(TASK_STORE_KEY);
38
- if (!raw)
39
- return [];
40
- const arr = JSON.parse(raw);
41
- return Array.isArray(arr) ? arr : [];
42
- }
43
- catch {
44
- return [];
45
- }
46
- }
47
- function saveTasks(tasks) {
48
- try {
49
- localStorage.setItem(TASK_STORE_KEY, JSON.stringify(tasks));
50
- }
51
- catch { /* */ }
52
- }
53
- /** Fetch upcoming events from Google Calendar via the primary account's
54
- * OAuth token. Returns merged Google + local events for the next 30 days
55
- * starting at `from`. Quietly returns local-only on any error so the
56
- * sidebar still works without network / without Google Calendar scope. */
22
+ /** Fetch events from the local two-way cache; service returns local rows
23
+ * immediately and kicks a background refresh from Google. Next render
24
+ * (view-nav or user action) picks up the refreshed rows. No localStorage
25
+ * — everything lives in the service-side DB so phone / desktop share
26
+ * the same events. */
57
27
  async function fetchUpcoming(from) {
58
- const local = loadLocalEvents();
59
- let google = [];
60
- try {
61
- const primary = await getPrimaryAccount("calendar");
62
- if (primary?.email) {
63
- // The OAuth token is held by the service-side OAuthTokenManager;
64
- // calendar fetch goes through a server-side proxy method (not
65
- // implemented yet — this is the seam). For now: local-only.
66
- // Wire-up: TODO — service.fetchGoogleCalendarEvents(accountId, from, days).
67
- // Sidebar already renders correctly with the merged result.
68
- }
69
- }
70
- catch { /* google unavailable, fall back to local */ }
71
28
  const horizon = from.getTime() + 30 * 86400_000;
72
- return [...local, ...google]
73
- .filter(e => e.start >= from.getTime() && e.start < horizon)
74
- .sort((a, b) => a.start - b.start);
29
+ const rows = await getCalendarEvents(from.getTime(), horizon);
30
+ return rows.map((r) => ({
31
+ id: r.uuid,
32
+ title: r.title,
33
+ start: r.startMs,
34
+ end: r.endMs,
35
+ allDay: !!r.allDay,
36
+ location: r.location,
37
+ notes: r.notes,
38
+ source: r.providerId ? "google" : "local",
39
+ }));
75
40
  }
76
41
  function formatDayHeader(d, today, tomorrow) {
77
42
  const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
@@ -124,9 +89,9 @@ function renderEvents(events) {
124
89
  }
125
90
  body.innerHTML = html;
126
91
  }
127
- function renderTasks() {
92
+ async function renderTasks() {
128
93
  const showDone = document.getElementById("cal-side-show-done")?.checked || false;
129
- const tasks = loadTasks().filter(t => showDone || !t.completed);
94
+ const tasks = await getTasks(showDone);
130
95
  const host = document.getElementById("cal-side-tasks");
131
96
  if (!host)
132
97
  return;
@@ -136,23 +101,24 @@ function renderTasks() {
136
101
  }
137
102
  let html = "<div class='cal-side-task-head'>Title</div>";
138
103
  for (const t of tasks) {
139
- html += `<div class="cal-side-task" data-id="${t.id}">
140
- <input type="checkbox" ${t.completed ? "checked" : ""} class="cal-side-task-check">
141
- <span class="cal-side-task-title${t.completed ? " done" : ""}">${escapeHtml(t.title)}</span>
104
+ const done = !!t.completedMs;
105
+ html += `<div class="cal-side-task" data-uuid="${t.uuid}">
106
+ <input type="checkbox" ${done ? "checked" : ""} class="cal-side-task-check">
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>
142
109
  </div>`;
143
110
  }
144
111
  host.innerHTML = html;
145
112
  host.querySelectorAll(".cal-side-task").forEach(row => {
146
- const id = row.dataset.id;
147
- row.querySelector(".cal-side-task-check")?.addEventListener("change", (e) => {
113
+ const uuid = row.dataset.uuid;
114
+ row.querySelector(".cal-side-task-check")?.addEventListener("change", async (e) => {
148
115
  const checked = e.target.checked;
149
- const all = loadTasks();
150
- const t = all.find(x => x.id === id);
151
- if (t) {
152
- t.completed = checked ? Date.now() : undefined;
153
- saveTasks(all);
154
- renderTasks();
155
- }
116
+ await updateTask(uuid, { completedMs: checked ? Date.now() : null });
117
+ renderTasks();
118
+ });
119
+ row.querySelector(".cal-side-task-delete")?.addEventListener("click", async () => {
120
+ await deleteTask(uuid);
121
+ renderTasks();
156
122
  });
157
123
  });
158
124
  }
@@ -225,19 +191,19 @@ export function initCalendarSidebar() {
225
191
  alert("Couldn't parse that date.");
226
192
  return;
227
193
  }
228
- const ev = {
229
- id: `cal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
230
- title, start, end: start + (allDay ? 86400_000 : 3600_000),
231
- allDay, source: "local",
232
- };
233
- const all = loadLocalEvents();
234
- all.push(ev);
235
- try {
236
- localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify(all));
237
- }
238
- catch { /* */ }
194
+ // Two-way cache: commit locally, service queues the push to Google.
195
+ await createCalendarEvent({
196
+ title, startMs: start, endMs: start + (allDay ? 86400_000 : 3600_000), allDay,
197
+ });
239
198
  await refresh();
240
199
  });
200
+ wireOnce("cal-side-new-task", async () => {
201
+ const title = prompt("Task title:");
202
+ if (!title)
203
+ return;
204
+ await createTask({ title });
205
+ renderTasks();
206
+ });
241
207
  const showDoneCb = document.getElementById("cal-side-show-done");
242
208
  if (showDoneCb && !showDoneCb.__wired) {
243
209
  showDoneCb.__wired = true;
@@ -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>
@@ -170,7 +171,10 @@
170
171
  <div class="cal-side-empty">Loading…</div>
171
172
  </div>
172
173
  <footer class="cal-side-foot">
173
- <label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
174
+ <div class="cal-side-task-header-row">
175
+ <label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
176
+ <button class="cal-side-new" id="cal-side-new-task" title="New task">+ Task</button>
177
+ </div>
174
178
  <div class="cal-side-tasks" id="cal-side-tasks"></div>
175
179
  </footer>
176
180
  </aside>
@@ -178,6 +182,7 @@
178
182
  <footer class="status-bar" id="status-bar">
179
183
  <span id="status-accounts"></span>
180
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>
181
186
  <span id="status-diag" class="status-diag" hidden title=""></span>
182
187
  <span id="status-pending"></span>
183
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();
@@ -141,6 +141,35 @@ export function getDiagnostics() {
141
141
  export function getPrimaryAccount(feature) {
142
142
  return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);
143
143
  }
144
+ // Calendar / Tasks: two-way cache. Reads return local-cached rows; writes
145
+ // commit locally and queue a push to Google. Service layer handles drain.
146
+ export function getCalendarEvents(fromMs, toMs) {
147
+ return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);
148
+ }
149
+ export function createCalendarEvent(ev) {
150
+ return ipc().createCalendarEvent?.(ev);
151
+ }
152
+ export function updateCalendarEvent(uuid, patch) {
153
+ return ipc().updateCalendarEvent?.(uuid, patch);
154
+ }
155
+ export function deleteCalendarEvent(uuid) {
156
+ return ipc().deleteCalendarEvent?.(uuid);
157
+ }
158
+ export function getTasks(includeCompleted = false) {
159
+ return ipc().getTasks?.(includeCompleted) ?? Promise.resolve([]);
160
+ }
161
+ export function createTask(t) {
162
+ return ipc().createTask?.(t);
163
+ }
164
+ export function updateTask(uuid, patch) {
165
+ return ipc().updateTask?.(uuid, patch);
166
+ }
167
+ export function deleteTask(uuid) {
168
+ return ipc().deleteTask?.(uuid);
169
+ }
170
+ export function drainStoreSync() {
171
+ return ipc().drainStoreSync?.();
172
+ }
144
173
  export function getOutboxStatus() {
145
174
  return ipc().getOutboxStatus();
146
175
  }