@bobfrankston/mailx 1.0.244 → 1.0.251

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
@@ -513,14 +513,14 @@ async function runTest() {
513
513
  // Test IMAP
514
514
  try {
515
515
  const { createAutoImapConfig, CompatImapClient } = await import("@bobfrankston/iflow-direct");
516
- const { NodeTransport } = await import("@bobfrankston/iflow-node");
516
+ const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
517
517
  const config = createAutoImapConfig({
518
518
  server: account.imap.host,
519
519
  port: account.imap.port,
520
520
  username: account.imap.user,
521
521
  password: account.imap.password
522
522
  });
523
- const client = new CompatImapClient(config, () => new NodeTransport());
523
+ const client = new CompatImapClient(config, () => new NodeTcpTransport());
524
524
  const folders = await client.getFolderList();
525
525
  await client.logout();
526
526
  console.log(` IMAP: OK (${folders.length} folders)`);
@@ -698,8 +698,8 @@ async function main() {
698
698
  }
699
699
  }
700
700
  const db = new MailxDB(getConfigDir());
701
- const { NodeTransport } = await import("@bobfrankston/iflow-node");
702
- const imapManager = new ImapManager(db, () => new NodeTransport());
701
+ const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
702
+ const imapManager = new ImapManager(db, () => new NodeTcpTransport());
703
703
  // Native client is the only option (iflow-direct)
704
704
  const svc = new MailxService(db, imapManager);
705
705
  // Open msger in service mode — custom protocol serves files from client dir
@@ -769,6 +769,22 @@ async function main() {
769
769
  }, 1000); // batch count updates every 1s
770
770
  }
771
771
  });
772
+ // Batch folderSynced events (same cadence as counts) so a sync-all burst
773
+ // doesn't flood the WebView with one stdin write per folder.
774
+ let pendingSynced = {};
775
+ let syncedTimer = null;
776
+ imapManager.on("folderSynced", (accountId, folderId, syncedAt) => {
777
+ (pendingSynced[accountId] ||= []).push({ folderId, syncedAt });
778
+ if (!syncedTimer) {
779
+ syncedTimer = setTimeout(() => {
780
+ syncedTimer = null;
781
+ for (const [id, entries] of Object.entries(pendingSynced)) {
782
+ handle.send({ _event: "folderSynced", type: "folderSynced", accountId: id, entries });
783
+ }
784
+ pendingSynced = {};
785
+ }, 1000);
786
+ }
787
+ });
772
788
  imapManager.on("syncError", (accountId, error) => {
773
789
  handle.send({ _event: "error", type: "error", message: `${accountId}: ${error}` });
774
790
  });
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":419,"y":152}
1
+ {"height":1344,"width":2151,"x":380,"y":73}
@@ -17,6 +17,10 @@
17
17
  "@bobfrankston/mailx-types": "../packages/mailx-types/index.js",
18
18
  "@bobfrankston/iflow-direct": "../node_modules/@bobfrankston/iflow-direct/index.js",
19
19
  "@bobfrankston/iflow-direct/": "../node_modules/@bobfrankston/iflow-direct/",
20
+ "@bobfrankston/smtp-direct": "../node_modules/@bobfrankston/smtp-direct/index.js",
21
+ "@bobfrankston/smtp-direct/": "../node_modules/@bobfrankston/smtp-direct/",
22
+ "@bobfrankston/tcp-transport": "../node_modules/@bobfrankston/tcp-transport/index.js",
23
+ "@bobfrankston/tcp-transport/": "../node_modules/@bobfrankston/tcp-transport/",
20
24
  "sql.js": "../packages/mailx-store-web/sql-wasm-esm.js"
21
25
  }
22
26
  }
package/client/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * mailx client entry point.
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
- import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
5
+ import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
8
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";
@@ -154,6 +154,44 @@ let currentFolderSpecialUse = "";
154
154
  function clearViewer() {
155
155
  messageState.select(null); // Deselect — viewer clears via subscription
156
156
  }
157
+ const folderTitleEl = document.getElementById("ml-folder-title");
158
+ let currentFolderName = "";
159
+ let currentFolderSyncedAt;
160
+ function formatAge(ms) {
161
+ const s = Math.round(ms / 1000);
162
+ if (s < 60)
163
+ return `${s}s ago`;
164
+ const m = Math.round(s / 60);
165
+ if (m < 60)
166
+ return `${m}m ago`;
167
+ const h = Math.round(m / 60);
168
+ if (h < 24)
169
+ return `${h}h ago`;
170
+ return `${Math.round(h / 24)}d ago`;
171
+ }
172
+ function renderNarrowFolderTitle() {
173
+ if (!folderTitleEl)
174
+ return;
175
+ if (currentFolderSyncedAt) {
176
+ const age = formatAge(Date.now() - currentFolderSyncedAt);
177
+ folderTitleEl.innerHTML = `${currentFolderName}<span class="ml-folder-age"> · ${age}</span>`;
178
+ folderTitleEl.title = `Last synced ${new Date(currentFolderSyncedAt).toLocaleTimeString()}`;
179
+ }
180
+ else {
181
+ folderTitleEl.textContent = currentFolderName;
182
+ folderTitleEl.title = "";
183
+ }
184
+ }
185
+ function setNarrowFolderTitle(name) {
186
+ currentFolderName = name;
187
+ currentFolderSyncedAt = getFolderSynced(currentAccountId, currentFolderId);
188
+ renderNarrowFolderTitle();
189
+ }
190
+ // Tick the "3m ago" text every 30s so it stays truthful without flooding repaints.
191
+ setInterval(() => {
192
+ if (currentFolderSyncedAt)
193
+ renderNarrowFolderTitle();
194
+ }, 30_000);
157
195
  initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
158
196
  currentFolderSpecialUse = specialUse;
159
197
  currentAccountId = accountId;
@@ -164,12 +202,14 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
164
202
  clearViewer();
165
203
  loadMessages(accountId, folderId, 1, specialUse);
166
204
  setTitle(`mailx - ${folderName}`);
205
+ setNarrowFolderTitle(folderName);
167
206
  }, () => {
168
207
  // Unified inbox handler
169
208
  currentFolderSpecialUse = "inbox";
170
209
  clearViewer();
171
210
  loadUnifiedInbox();
172
211
  setTitle("mailx - All Inboxes");
212
+ setNarrowFolderTitle("All Inboxes");
173
213
  });
174
214
  initMessageList((accountId, uid, folderId) => {
175
215
  showMessage(accountId, uid, folderId, currentFolderSpecialUse);
@@ -216,14 +256,27 @@ if (messageList) {
216
256
  document.getElementById("btn-menu")?.addEventListener("click", () => {
217
257
  document.querySelector(".folder-panel")?.classList.toggle("open");
218
258
  });
219
- document.getElementById("btn-back")?.addEventListener("click", () => {
259
+ const backToList = (e) => {
260
+ e.preventDefault();
261
+ e.stopPropagation();
220
262
  document.getElementById("message-viewer")?.classList.remove("narrow-active");
221
263
  document.getElementById("message-list")?.classList.remove("narrow-hidden");
222
- });
264
+ // Deselect the message so the viewer component clears. Without this, a
265
+ // subsequent "selected" state change (e.g. sync reload) could re-show the
266
+ // same message and re-trigger narrow-active.
267
+ messageState.select(null);
268
+ };
269
+ document.getElementById("btn-back")?.addEventListener("click", backToList);
270
+ // Android WebView sometimes drops synthetic clicks after a touchend inside a
271
+ // header bar layered above the iframe — handle touchend explicitly too.
272
+ document.getElementById("btn-back")?.addEventListener("touchend", backToList);
223
273
  // Close folder panel when a folder is selected (narrow mode)
274
+ // Also reset narrow navigation: show message list, hide viewer
224
275
  document.getElementById("folder-tree")?.addEventListener("click", (e) => {
225
276
  if (window.innerWidth <= 768 && e.target.closest(".ft-folder")) {
226
277
  document.querySelector(".folder-panel")?.classList.remove("open");
278
+ document.getElementById("message-viewer")?.classList.remove("narrow-active");
279
+ document.getElementById("message-list")?.classList.remove("narrow-hidden");
227
280
  }
228
281
  });
229
282
  // Close folder overlay when user clicks outside it (narrow mode OR
@@ -289,6 +342,14 @@ document.getElementById("btn-restart-quick")?.addEventListener("click", async ()
289
342
  if (restartDropdown)
290
343
  restartDropdown.hidden = true;
291
344
  if (isApp) {
345
+ // Android: check for updates before reloading
346
+ if (window.mailxapi?.platform === "android") {
347
+ const f = document.createElement("iframe");
348
+ f.style.display = "none";
349
+ f.src = "mailxapi://checkUpdate";
350
+ document.body.appendChild(f);
351
+ setTimeout(() => f.remove(), 100);
352
+ }
292
353
  // IPC mode: reload the UI (no server to restart)
293
354
  location.reload();
294
355
  }
@@ -755,10 +816,11 @@ window.addEventListener("message", (e) => {
755
816
  if (e.data?.type === "linkClick" && e.data.url) {
756
817
  const url = e.data.url;
757
818
  if (window.mailxapi?.platform === "android") {
758
- // Android: use a hidden iframe to trigger OnNavigating which opens in Chrome
819
+ // Android: use mailxapi:// bridge scheme OnNavigating intercepts it
820
+ // and opens in system browser. Raw http:// in sub-frames doesn't trigger OnNavigating.
759
821
  const f = document.createElement("iframe");
760
822
  f.style.display = "none";
761
- f.src = url;
823
+ f.src = `mailxapi://openurl?url=${encodeURIComponent(url)}`;
762
824
  document.body.appendChild(f);
763
825
  setTimeout(() => f.remove(), 500);
764
826
  }
@@ -855,6 +917,16 @@ onWsEvent((event) => {
855
917
  // where folders don't exist until sync fetches them from Gmail API)
856
918
  refreshFolderTree();
857
919
  break;
920
+ case "folderSynced":
921
+ // Per-folder timestamps — drives the tooltip + freshness dot.
922
+ for (const entry of event.entries || []) {
923
+ setFolderSynced(event.accountId, entry.folderId, entry.syncedAt);
924
+ if (currentFolderId === entry.folderId && currentAccountId === event.accountId) {
925
+ currentFolderSyncedAt = entry.syncedAt;
926
+ renderNarrowFolderTitle();
927
+ }
928
+ }
929
+ break;
858
930
  case "folderCountsChanged": {
859
931
  // Update folder badges + silently refresh message list (preserves selection and viewer)
860
932
  updateFolderCounts();
@@ -1339,18 +1411,19 @@ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
1339
1411
  // Wait for server ready signal, then fetch version
1340
1412
  const versionPromise = getVersion();
1341
1413
  versionPromise.then((d) => {
1342
- const el = document.getElementById("app-version");
1414
+ const els = document.querySelectorAll(".app-version");
1343
1415
  const storage = d.storage || {};
1344
1416
  const storageLabel = storage.provider && storage.provider !== "local"
1345
1417
  ? ` [${storage.provider}]`
1346
1418
  : "";
1347
- if (el) {
1348
- el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
1349
- // Tooltip: show Drive path on hover
1350
- if (storage.provider && storage.provider !== "local") {
1351
- const drivePath = storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider;
1352
- el.title = drivePath;
1353
- }
1419
+ const text = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
1420
+ const tip = storage.provider && storage.provider !== "local"
1421
+ ? (storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider)
1422
+ : "";
1423
+ for (const el of els) {
1424
+ el.textContent = text;
1425
+ if (tip)
1426
+ el.title = tip;
1354
1427
  }
1355
1428
  if (d.settingsError) {
1356
1429
  showAlert(d.settingsError, "settings-error");
@@ -1386,15 +1459,10 @@ versionPromise.then((d) => {
1386
1459
  }
1387
1460
  }).catch((e) => {
1388
1461
  // Version fetch failed
1389
- const el = document.getElementById("app-version");
1390
- if (isApp) {
1391
- if (el)
1392
- el.textContent = `mailx [version error: ${e.message}]`;
1393
- }
1394
- else {
1395
- if (el)
1396
- el.textContent = "mailx [server offline]";
1397
- }
1462
+ const els = document.querySelectorAll(".app-version");
1463
+ const text = isApp ? `mailx [version error: ${e.message}]` : "mailx [server offline]";
1464
+ for (const el of els)
1465
+ el.textContent = text;
1398
1466
  });
1399
1467
  // ── Sync pending indicator + server health check (HTTP mode only) ──
1400
1468
  let serverDown = false;
@@ -18,6 +18,47 @@ const expandState = JSON.parse(localStorage.getItem("mailx-folders-expanded") ||
18
18
  function saveExpandState() {
19
19
  localStorage.setItem("mailx-folders-expanded", JSON.stringify(expandState));
20
20
  }
21
+ // Last-sync tracking: populated by folderSynced events. Memory-only; fresh
22
+ // restarts start with an empty map and fill in as syncs happen.
23
+ const folderLastSync = new Map(); // key = `${accountId}:${folderId}`
24
+ function syncKey(accountId, folderId) {
25
+ return `${accountId}:${folderId}`;
26
+ }
27
+ export function setFolderSynced(accountId, folderId, syncedAt) {
28
+ folderLastSync.set(syncKey(accountId, folderId), syncedAt);
29
+ // Update the row in place if rendered — avoids a full re-render.
30
+ const el = document.querySelector(`.ft-folder[data-account-id="${CSS.escape(accountId)}"][data-folder-id="${folderId}"]`);
31
+ if (el)
32
+ applyFreshness(el, syncedAt);
33
+ }
34
+ export function getFolderSynced(accountId, folderId) {
35
+ return folderLastSync.get(syncKey(accountId, folderId));
36
+ }
37
+ function formatAge(ms) {
38
+ const secs = Math.round(ms / 1000);
39
+ if (secs < 60)
40
+ return `${secs}s ago`;
41
+ const mins = Math.round(secs / 60);
42
+ if (mins < 60)
43
+ return `${mins}m ago`;
44
+ const hours = Math.round(mins / 60);
45
+ if (hours < 24)
46
+ return `${hours}h ago`;
47
+ return `${Math.round(hours / 24)}d ago`;
48
+ }
49
+ function freshnessClass(ageMs) {
50
+ if (ageMs < 5 * 60_000)
51
+ return "fresh"; // green
52
+ if (ageMs < 30 * 60_000)
53
+ return "stale-soft"; // yellow
54
+ return "stale"; // red
55
+ }
56
+ function applyFreshness(el, syncedAt) {
57
+ const age = Date.now() - syncedAt;
58
+ el.classList.remove("fresh", "stale-soft", "stale");
59
+ el.classList.add(freshnessClass(age));
60
+ el.title = `Last synced: ${formatAge(age)} (${new Date(syncedAt).toLocaleTimeString()})`;
61
+ }
21
62
  /** Build a tree from flat folder list using delimiter */
22
63
  function buildTree(folders, delimiter, accountId) {
23
64
  const root = [];
@@ -140,10 +181,17 @@ function renderNode(node, container, depth) {
140
181
  toggle.textContent = " ";
141
182
  }
142
183
  folderEl.appendChild(toggle);
184
+ const freshnessDot = document.createElement("span");
185
+ freshnessDot.className = "ft-freshness";
186
+ freshnessDot.setAttribute("aria-hidden", "true");
187
+ folderEl.appendChild(freshnessDot);
143
188
  const nameSpan = document.createElement("span");
144
189
  nameSpan.className = "ft-folder-name";
145
190
  nameSpan.textContent = node.name;
146
191
  folderEl.appendChild(nameSpan);
192
+ const syncedAt = getFolderSynced(node.accountId, node.id);
193
+ if (syncedAt)
194
+ applyFreshness(folderEl, syncedAt);
147
195
  const isOutbox = node.specialUse === "outbox" || node.path.toLowerCase() === "outbox";
148
196
  if (isOutbox && node.totalCount > 0) {
149
197
  // Outbox: show total (pending) count with warning style
@@ -49,8 +49,13 @@ function clampZoom(z) {
49
49
  return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));
50
50
  }
51
51
  function applyZoom(doc) {
52
- if (doc.body)
53
- doc.body.style.zoom = String(previewZoom);
52
+ // Zoom lives on <html>, not <body>, because WebView2/Chromium's scroll
53
+ // container is the root element — body.style.zoom leaves the scroll
54
+ // container out of sync with the zoomed content, breaking scrollbar
55
+ // display and wheel scrolling. documentElement.style.zoom keeps them
56
+ // aligned so the iframe scrolls normally at any zoom level.
57
+ if (doc.documentElement)
58
+ doc.documentElement.style.zoom = String(previewZoom);
54
59
  }
55
60
  function setZoom(z, doc) {
56
61
  previewZoom = clampZoom(z);
@@ -96,6 +101,9 @@ function installPreviewControls(iframe) {
96
101
  e.preventDefault();
97
102
  setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);
98
103
  }, { passive: false });
104
+ // Link interception lives in the iframe's own inline <script> (see
105
+ // wrapHtmlBody). That script runs under a CSP nonce so email-body
106
+ // scripts stay blocked while ours forwards taps to the parent frame.
99
107
  doc.addEventListener("contextmenu", (e) => {
100
108
  e.preventDefault();
101
109
  const me = e;
@@ -411,6 +419,12 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
411
419
  iframe.sandbox.add("allow-popups");
412
420
  iframe.sandbox.add("allow-popups-to-escape-sandbox");
413
421
  iframe.sandbox.add("allow-top-navigation-by-user-activation");
422
+ // allow-scripts lets OUR injected <script> run (for Android link
423
+ // interception — parent-side contentDocument listeners don't fire
424
+ // reliably on Android WebView). CSP with a nonce restricts script
425
+ // execution to our tag only; inline scripts in the email body are
426
+ // still blocked.
427
+ iframe.sandbox.add("allow-scripts");
414
428
  iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
415
429
  bodyEl.appendChild(iframe);
416
430
  installPreviewControls(iframe);
@@ -503,7 +517,14 @@ function formatSize(bytes) {
503
517
  return `${(bytes / 1048576).toFixed(1)} MB`;
504
518
  }
505
519
  function wrapHtmlBody(html, allowRemote = false) {
506
- // CSP blocks remote resource loading (tracking pixels, external CSS) but allows link clicks
520
+ // CSP blocks remote resources (tracking pixels, external CSS). Inline
521
+ // scripts are allowed via 'unsafe-inline' so our injected link-tap handler
522
+ // runs; email-body <script> tags and on* handlers are stripped server-side
523
+ // by sanitizeHtml() in mailx-core, so this doesn't actually widen the
524
+ // attack surface. (A per-render nonce would be tidier, but meta-CSP with
525
+ // nonces isn't reliably honored across older WebViews — and when a nonce
526
+ // is present, 'unsafe-inline' is ignored, so our script fell back to
527
+ // blocked on those WebViews.)
507
528
  const csp = allowRemote
508
529
  ? ""
509
530
  : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: cid:; form-action 'none';">`;
@@ -512,6 +533,7 @@ function wrapHtmlBody(html, allowRemote = false) {
512
533
  <meta charset="UTF-8">
513
534
  ${csp}
514
535
  <style>
536
+ html { height: 100%; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; }
515
537
  body {
516
538
  font-family: system-ui, sans-serif;
517
539
  font-size: 17.5px;
@@ -520,6 +542,7 @@ ${csp}
520
542
  background: #fff;
521
543
  padding: 1rem;
522
544
  margin: 0;
545
+ min-height: 100%;
523
546
  word-break: break-word;
524
547
  color-scheme: dark light;
525
548
  }
@@ -535,19 +558,34 @@ ${csp}
535
558
  </style>
536
559
  <base target="_blank">
537
560
  <script>
538
- document.addEventListener("mouseover", e => {
539
- const a = e.target.closest("a[href]");
540
- window.parent.postMessage({ type: "linkHover", url: a ? a.href : "" }, "*");
541
- });
542
- // Intercept link clicks — Android WebView silently drops window.open, so forward to parent
543
- document.addEventListener("click", e => {
544
- const a = e.target.closest("a[href]");
545
- if (!a) return;
546
- const url = a.href;
547
- if (!url || url.startsWith("javascript:") || url.startsWith("#")) return;
548
- e.preventDefault();
549
- window.parent.postMessage({ type: "linkClick", url: url }, "*");
550
- }, true);
561
+ // Link interception — Android WebView doesn't fire the default <a target="_blank">
562
+ // new-window handler, so we postMessage to the parent which routes through the
563
+ // native bridge (mailxapi://openurl) to Launcher.OpenAsync.
564
+ (function () {
565
+ function handleLinkTap(e) {
566
+ var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
567
+ if (!a) return;
568
+ var url = a.href;
569
+ if (!url || url.indexOf("javascript:") === 0 || url.charAt(0) === "#") return;
570
+ e.preventDefault();
571
+ e.stopPropagation();
572
+ window.parent.postMessage({ type: "linkClick", url: url }, "*");
573
+ }
574
+ document.addEventListener("click", handleLinkTap, true);
575
+ var lastTouchTarget = null;
576
+ document.addEventListener("touchstart", function (e) {
577
+ lastTouchTarget = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
578
+ }, true);
579
+ document.addEventListener("touchend", function (e) {
580
+ var t = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
581
+ if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);
582
+ lastTouchTarget = null;
583
+ }, true);
584
+ document.addEventListener("mouseover", function (e) {
585
+ var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
586
+ window.parent.postMessage({ type: "linkHover", url: a ? a.href : "" }, "*");
587
+ });
588
+ })();
551
589
  </script>
552
590
  </head><body>${html}</body></html>`;
553
591
  }
package/client/index.html CHANGED
@@ -15,7 +15,7 @@
15
15
  <div class="toolbar-left">
16
16
  <button class="tb-btn" id="btn-menu" title="Folders" hidden>☰</button>
17
17
  <button class="tb-btn" id="btn-compose" title="Compose (Ctrl+N)">
18
- <span class="tb-icon">✏</span> Compose
18
+ <span class="tb-icon">✏</span><span class="tb-label"> Compose</span>
19
19
  </button>
20
20
  </div>
21
21
  <div class="toolbar-center">
@@ -46,11 +46,11 @@
46
46
  </div>
47
47
  <div class="toolbar-right">
48
48
  <button class="tb-btn" id="btn-sync" title="Sync all folders (F5)">
49
- <span class="tb-icon">↻</span> Sync
49
+ <span class="tb-icon">↻</span><span class="tb-label"> Sync</span>
50
50
  </button>
51
51
  <div class="tb-menu" id="restart-menu">
52
52
  <button class="tb-btn" id="btn-restart" title="Reload">
53
- <span class="tb-icon">⚡</span> Restart
53
+ <span class="tb-icon">⚡</span><span class="tb-label"> Restart ▾</span>
54
54
  </button>
55
55
  <div class="tb-menu-dropdown" id="restart-dropdown" hidden>
56
56
  <button class="tb-menu-item" id="btn-restart-quick" title="Reload the page">Reload</button>
@@ -86,6 +86,7 @@
86
86
  </select>
87
87
  <input type="search" id="search-input" placeholder="Search... (/regex/)" autocomplete="off" title="Search messages. /pattern/ for regex. Qualifiers: from: to: subject:">
88
88
  </search>
89
+ <div class="ml-folder-title" id="ml-folder-title"></div>
89
90
  <div class="ml-header">
90
91
  <span class="ml-col ml-col-flag"></span>
91
92
  <span class="ml-col ml-col-from" data-sort="from">From</span>
@@ -134,6 +135,7 @@
134
135
  <span id="status-sync">Syncing...</span>
135
136
  <span id="status-pending"></span>
136
137
  <span id="status-queue"></span>
138
+ <span class="app-version" id="status-version">mailx</span>
137
139
  </footer>
138
140
 
139
141
  <div id="startup-overlay" class="startup-overlay">
@@ -334,6 +334,43 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
334
334
  .ml-col { cursor: pointer; &:hover { color: var(--color-text); } }
335
335
  }
336
336
 
337
+ /* Narrow-mode folder title above the list — hidden on wide where the window
338
+ title and toolbar already convey location. */
339
+ .ml-folder-title {
340
+ grid-column: 1 / -1;
341
+ display: none;
342
+ padding: var(--gap-sm) var(--gap-md);
343
+ border-bottom: 1px solid var(--color-border);
344
+ background: var(--color-bg-surface);
345
+ font-size: var(--font-size-lg);
346
+ font-weight: 600;
347
+ color: var(--color-text);
348
+ user-select: none;
349
+ }
350
+ .ml-folder-age {
351
+ font-weight: 400;
352
+ font-size: var(--font-size-sm);
353
+ color: var(--color-text-muted);
354
+ margin-left: var(--gap-xs);
355
+ }
356
+
357
+ /* Per-folder freshness dot — empty by default, colored once the folder has
358
+ a known last-sync time. Fresh (<5m) green, stale-soft (<30m) yellow,
359
+ stale (30m+) red. */
360
+ .ft-folder .ft-freshness {
361
+ display: inline-block;
362
+ width: 8px;
363
+ height: 8px;
364
+ margin-right: 6px;
365
+ border-radius: 50%;
366
+ background: transparent;
367
+ border: 1px solid color-mix(in oklch, var(--color-text-muted) 40%, transparent);
368
+ flex-shrink: 0;
369
+ }
370
+ .ft-folder.fresh .ft-freshness { background: #4a9; border-color: transparent; }
371
+ .ft-folder.stale-soft .ft-freshness { background: #d7a324; border-color: transparent; }
372
+ .ft-folder.stale .ft-freshness { background: #c74; border-color: transparent; }
373
+
337
374
  .ml-body {
338
375
  grid-column: 1 / -1;
339
376
  overflow-y: auto;
@@ -91,6 +91,25 @@ body {
91
91
  @media (max-width: 768px), (max-height: 600px) {
92
92
  /* Hide preview snippet under message subject — save space */
93
93
  .ml-preview { display: none; }
94
+ /* Column headers (From/Date/Subject) take space without being useful on narrow */
95
+ .ml-header { display: none; }
96
+ /* Current folder name shown above the list, Dovecot-style */
97
+ .ml-folder-title { display: block; }
98
+ /* Version string overflows the toolbar on narrow — move to the status bar */
99
+ #app-version { display: none; }
100
+ #status-version {
101
+ color: var(--color-text-muted);
102
+ font-size: var(--font-size-sm);
103
+ margin-left: auto;
104
+ }
105
+ /* Drop button captions on narrow — icons are self-explanatory and captions overflow */
106
+ .toolbar .tb-label { display: none; }
107
+ /* View and Settings menus don't need to shout on narrow */
108
+ #view-menu, #settings-menu { display: none; }
109
+ }
110
+ @media (min-width: 769px) {
111
+ /* Status-bar version is a narrow-only mirror — keep it hidden on wide */
112
+ #status-version { display: none; }
94
113
  }
95
114
  @media (max-width: 768px), (max-height: 600px) {
96
115
  body {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.244",
3
+ "version": "1.0.251",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,11 +20,11 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow-direct": "^0.1.12",
24
- "@bobfrankston/iflow-node": "^0.1.2",
23
+ "@bobfrankston/iflow-direct": "^0.1.15",
24
+ "@bobfrankston/iflow-node": "^0.1.5",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.306",
27
+ "@bobfrankston/msger": "^0.1.313",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -34,7 +34,10 @@
34
34
  "nodemailer": "^7.0.0",
35
35
  "quill": "^2.0.3",
36
36
  "ws": "^8.18.0",
37
- "sql.js": "^1.14.1"
37
+ "sql.js": "^1.14.1",
38
+ "@bobfrankston/tcp-transport": "^0.1.3",
39
+ "@bobfrankston/node-tcp-transport": "^0.1.1",
40
+ "@bobfrankston/smtp-direct": "^0.1.2"
38
41
  },
39
42
  "devDependencies": {
40
43
  "@types/mailparser": "^3.4.6"
@@ -70,15 +73,18 @@
70
73
  "nodemailer": "^7.0.0",
71
74
  "quill": "^2.0.3",
72
75
  "ws": "^8.18.0",
73
- "sql.js": "^1.14.1"
76
+ "sql.js": "^1.14.1",
77
+ "@bobfrankston/tcp-transport": "file:../MailApps/tcp-transport",
78
+ "@bobfrankston/node-tcp-transport": "file:../MailApps/node-tcp-transport",
79
+ "@bobfrankston/smtp-direct": "file:../MailApps/smtp-direct"
74
80
  },
75
81
  ".transformedSnapshot": {
76
82
  "dependencies": {
77
- "@bobfrankston/iflow-direct": "^0.1.12",
78
- "@bobfrankston/iflow-node": "^0.1.2",
83
+ "@bobfrankston/iflow-direct": "^0.1.15",
84
+ "@bobfrankston/iflow-node": "^0.1.5",
79
85
  "@bobfrankston/miscinfo": "^1.0.8",
80
86
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.306",
87
+ "@bobfrankston/msger": "^0.1.313",
82
88
  "@capacitor/android": "^8.3.0",
83
89
  "@capacitor/cli": "^8.3.0",
84
90
  "@capacitor/core": "^8.3.0",
@@ -88,7 +94,10 @@
88
94
  "nodemailer": "^7.0.0",
89
95
  "quill": "^2.0.3",
90
96
  "ws": "^8.18.0",
91
- "sql.js": "^1.14.1"
97
+ "sql.js": "^1.14.1",
98
+ "@bobfrankston/tcp-transport": "^0.1.3",
99
+ "@bobfrankston/node-tcp-transport": "^0.1.1",
100
+ "@bobfrankston/smtp-direct": "^0.1.2"
92
101
  }
93
102
  }
94
103
  }