@bobfrankston/mailx 1.0.246 → 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":736,"y":147}
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,10 +256,20 @@ 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)
224
274
  // Also reset narrow navigation: show message list, hide viewer
225
275
  document.getElementById("folder-tree")?.addEventListener("click", (e) => {
@@ -292,6 +342,14 @@ document.getElementById("btn-restart-quick")?.addEventListener("click", async ()
292
342
  if (restartDropdown)
293
343
  restartDropdown.hidden = true;
294
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
+ }
295
353
  // IPC mode: reload the UI (no server to restart)
296
354
  location.reload();
297
355
  }
@@ -859,6 +917,16 @@ onWsEvent((event) => {
859
917
  // where folders don't exist until sync fetches them from Gmail API)
860
918
  refreshFolderTree();
861
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;
862
930
  case "folderCountsChanged": {
863
931
  // Update folder badges + silently refresh message list (preserves selection and viewer)
864
932
  updateFolderCounts();
@@ -1343,18 +1411,19 @@ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
1343
1411
  // Wait for server ready signal, then fetch version
1344
1412
  const versionPromise = getVersion();
1345
1413
  versionPromise.then((d) => {
1346
- const el = document.getElementById("app-version");
1414
+ const els = document.querySelectorAll(".app-version");
1347
1415
  const storage = d.storage || {};
1348
1416
  const storageLabel = storage.provider && storage.provider !== "local"
1349
1417
  ? ` [${storage.provider}]`
1350
1418
  : "";
1351
- if (el) {
1352
- el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
1353
- // Tooltip: show Drive path on hover
1354
- if (storage.provider && storage.provider !== "local") {
1355
- const drivePath = storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider;
1356
- el.title = drivePath;
1357
- }
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;
1358
1427
  }
1359
1428
  if (d.settingsError) {
1360
1429
  showAlert(d.settingsError, "settings-error");
@@ -1390,15 +1459,10 @@ versionPromise.then((d) => {
1390
1459
  }
1391
1460
  }).catch((e) => {
1392
1461
  // Version fetch failed
1393
- const el = document.getElementById("app-version");
1394
- if (isApp) {
1395
- if (el)
1396
- el.textContent = `mailx [version error: ${e.message}]`;
1397
- }
1398
- else {
1399
- if (el)
1400
- el.textContent = "mailx [server offline]";
1401
- }
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;
1402
1466
  });
1403
1467
  // ── Sync pending indicator + server health check (HTTP mode only) ──
1404
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,27 +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
- // Listen for both click and touchend since click may not fire on some Android WebViews
544
- function handleLinkTap(e) {
545
- const a = e.target.closest("a[href]");
546
- if (!a) return;
547
- const url = a.href;
548
- if (!url || url.startsWith("javascript:") || url.startsWith("#")) return;
549
- e.preventDefault();
550
- window.parent.postMessage({ type: "linkClick", url: url }, "*");
551
- }
552
- document.addEventListener("click", handleLinkTap, true);
553
- let lastTouchTarget = null;
554
- document.addEventListener("touchstart", e => { lastTouchTarget = e.target; }, true);
555
- document.addEventListener("touchend", e => {
556
- if (lastTouchTarget && lastTouchTarget === e.target) handleLinkTap(e);
557
- lastTouchTarget = null;
558
- }, 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
+ })();
559
589
  </script>
560
590
  </head><body>${html}</body></html>`;
561
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.246",
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.13",
24
- "@bobfrankston/iflow-node": "^0.1.3",
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.307",
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.13",
78
- "@bobfrankston/iflow-node": "^0.1.3",
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.307",
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
  }
@@ -3,7 +3,7 @@
3
3
  * Multi-account IMAP management wrapping iflow.
4
4
  * Syncs messages to local store, emits events for new mail.
5
5
  */
6
- import { type TransportFactory } from "@bobfrankston/iflow-direct";
6
+ import type { TransportFactory } from "@bobfrankston/tcp-transport";
7
7
  import { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
8
8
  import type { AccountConfig, MessageEnvelope, Folder } from "@bobfrankston/mailx-types";
9
9
  import { EventEmitter } from "node:events";
@@ -208,7 +208,9 @@ export declare class ImapManager extends EventEmitter {
208
208
  * directory is safe again. Any legitimate files that land there (crash
209
209
  * recovery, manual drop) will get sent. */
210
210
  private processLocalQueue;
211
- /** Send a raw RFC 2822 message via SMTP for a given account */
211
+ /** Send a raw RFC 2822 message via SMTP for a given account.
212
+ * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
213
+ * same TCP byte-stream interface, no nodemailer dependency. */
212
214
  private sendRawViaSMTP;
213
215
  /** Process Outbox — send pending messages with flag-based interlock */
214
216
  processOutbox(accountId: string): Promise<void>;
@@ -12,7 +12,9 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { simpleParser } from "mailparser";
14
14
  import { GmailApiProvider } from "./providers/gmail-api.js";
15
+ import { SmtpClient } from "@bobfrankston/smtp-direct";
15
16
  import * as os from "node:os";
17
+ import { fileURLToPath } from "node:url";
16
18
  // Well-known ports — no magic numbers
17
19
  const SMTP_PORT_STARTTLS = 587;
18
20
  const SMTP_PORT_IMPLICIT_TLS = 465;
@@ -367,7 +369,10 @@ export class ImapManager extends EventEmitter {
367
369
  let credPath = path.join(getConfigDir(), "google-credentials.json");
368
370
  if (!fs.existsSync(credPath)) {
369
371
  try {
370
- const pkgDir = path.dirname(import.meta.resolve("@bobfrankston/iflow-direct").replace("file:///", "").replace("file://", ""));
372
+ // Use fileURLToPath, NOT string-replace on "file://" — on Linux,
373
+ // file:///usr/local/... loses its leading slash via .replace("file:///",
374
+ // "") and becomes relative, so fs.existsSync silently fails.
375
+ const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
371
376
  for (const name of ["iflow-credentials.json"]) {
372
377
  const p = path.join(pkgDir, name);
373
378
  if (fs.existsSync(p)) {
@@ -758,6 +763,7 @@ export class ImapManager extends EventEmitter {
758
763
  // Use recalcFolderCounts — single SQL query instead of fetching all messages
759
764
  this.db.recalcFolderCounts(folderId);
760
765
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
766
+ const syncedAt = Date.now();
761
767
  // Notify client to refresh if anything changed
762
768
  if (newCount > 0 || deletedCount > 0) {
763
769
  const updatedFolder = this.db.getFolders(accountId).find(f => f.id === folderId);
@@ -765,7 +771,8 @@ export class ImapManager extends EventEmitter {
765
771
  [folderId]: { total: updatedFolder?.totalCount || 0, unread: updatedFolder?.unreadCount || 0 }
766
772
  });
767
773
  }
768
- this.db.updateLastSync(accountId, Date.now());
774
+ this.emit("folderSynced", accountId, folderId, syncedAt);
775
+ this.db.updateLastSync(accountId, syncedAt);
769
776
  return newCount;
770
777
  }
771
778
  /** Sync all folders for all accounts */
@@ -1014,6 +1021,7 @@ export class ImapManager extends EventEmitter {
1014
1021
  }
1015
1022
  this.db.recalcFolderCounts(folder.id);
1016
1023
  this.emit("folderCountsChanged", accountId, {});
1024
+ this.emit("folderSynced", accountId, folder.id, Date.now());
1017
1025
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1018
1026
  }
1019
1027
  /** Store API-fetched messages to DB */
@@ -1977,6 +1985,37 @@ export class ImapManager extends EventEmitter {
1977
1985
  async processLocalQueue(accountId) {
1978
1986
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
1979
1987
  const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
1988
+ // Recovery sweep: any *.sending-<host>-<pid> on THIS host whose PID is
1989
+ // dead (process crashed mid-send) gets unclaimed so the next tick can
1990
+ // retry. Foreign hosts are left alone — we have no way to know if their
1991
+ // process is alive. Cross-host stale recovery is the IMAP-folder path's
1992
+ // job (sweeper looks at server-side claim flags, not local files).
1993
+ for (const dir of [outboxDir, queuedDir]) {
1994
+ if (!fs.existsSync(dir))
1995
+ continue;
1996
+ for (const f of fs.readdirSync(dir)) {
1997
+ const m = f.match(/^(.+)\.sending-([^-]+)-(\d+)$/);
1998
+ if (!m)
1999
+ continue;
2000
+ const [, original, host, pidStr] = m;
2001
+ if (host !== this.hostname)
2002
+ continue;
2003
+ const pid = parseInt(pidStr);
2004
+ let alive = false;
2005
+ try {
2006
+ process.kill(pid, 0);
2007
+ alive = true;
2008
+ }
2009
+ catch { /* dead */ }
2010
+ if (alive)
2011
+ continue; // live claim — owner (sibling or self) still has it
2012
+ try {
2013
+ fs.renameSync(path.join(dir, f), path.join(dir, original));
2014
+ console.log(` [outbox] Recovered stale claim ${f} → ${original}`);
2015
+ }
2016
+ catch { /* ignore */ }
2017
+ }
2018
+ }
1980
2019
  const filesToSend = [];
1981
2020
  for (const dir of [outboxDir, queuedDir]) {
1982
2021
  if (!fs.existsSync(dir))
@@ -1994,32 +2033,57 @@ export class ImapManager extends EventEmitter {
1994
2033
  const nowMs = Date.now();
1995
2034
  for (const { dir, file } of filesToSend) {
1996
2035
  const filePath = path.join(dir, file);
1997
- let raw = fs.readFileSync(filePath, "utf-8");
2036
+ // Atomic claim: rename to <file>.sending-<host>-<pid> so a sibling
2037
+ // process scanning the same dir can't grab the same .ltr. Filesystem
2038
+ // rename is atomic; loser sees ENOENT and skips. Without this, two
2039
+ // mailx instances on one machine (or two ticks within one process)
2040
+ // could both pass the Message-ID dedup check and both call SMTP.
2041
+ const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
2042
+ const claimedPath = filePath + claimSuffix;
2043
+ try {
2044
+ fs.renameSync(filePath, claimedPath);
2045
+ }
2046
+ catch (e) {
2047
+ if (e.code === "ENOENT")
2048
+ continue; // another process won
2049
+ throw e;
2050
+ }
2051
+ let raw = fs.readFileSync(claimedPath, "utf-8");
1998
2052
  // Per-message rate limit: if a prior attempt set X-Mailx-Retry-After
1999
2053
  // in the future, skip this file for now. Minimizes the race where the
2000
2054
  // SMTP server actually accepted DATA but we lost the ack and would
2001
2055
  // otherwise retry immediately on the next 10s tick.
2002
2056
  const retryInfo = parseRetryInfo(raw);
2003
- if (retryInfo.nextAttemptAt > nowMs)
2057
+ if (retryInfo.nextAttemptAt > nowMs) {
2058
+ // Release claim — let next tick reconsider
2059
+ try {
2060
+ fs.renameSync(claimedPath, filePath);
2061
+ }
2062
+ catch { /* ignore */ }
2004
2063
  continue;
2064
+ }
2005
2065
  // Record this attempt: strip internal X-Mailx-Retry-After, append a new
2006
2066
  // X-Mailx-Retry: N <ISO-timestamp> line to the headers. The updated file
2007
2067
  // is written back *before* the send so a crash mid-send doesn't lose state.
2008
2068
  const attempt = retryInfo.attemptCount + 1;
2009
2069
  raw = stripHeaderField(raw, "X-Mailx-Retry-After");
2010
2070
  raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
2011
- fs.writeFileSync(filePath, raw, "utf-8");
2071
+ fs.writeFileSync(claimedPath, raw, "utf-8");
2012
2072
  try {
2013
2073
  await this.sendRawViaSMTP(accountId, raw);
2014
- fs.renameSync(filePath, path.join(sentDir, file));
2074
+ fs.renameSync(claimedPath, path.join(sentDir, file));
2015
2075
  console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
2016
2076
  }
2017
2077
  catch (e) {
2018
- // Persist a next-attempt timestamp so the same file won't be retried
2019
- // until RETRY_DELAY_MS later gives the server time to settle.
2078
+ // Persist a next-attempt timestamp and release the claim so the
2079
+ // file is visible to the scan loop again.
2020
2080
  const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
2021
2081
  const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
2022
- fs.writeFileSync(filePath, withDelay, "utf-8");
2082
+ fs.writeFileSync(claimedPath, withDelay, "utf-8");
2083
+ try {
2084
+ fs.renameSync(claimedPath, filePath);
2085
+ }
2086
+ catch { /* file stays claimed; recovery sweeper will handle */ }
2023
2087
  console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
2024
2088
  }
2025
2089
  }
@@ -2050,36 +2114,34 @@ export class ImapManager extends EventEmitter {
2050
2114
  // IMAP still unreachable — leave files for next attempt
2051
2115
  }
2052
2116
  }
2053
- /** Send a raw RFC 2822 message via SMTP for a given account */
2117
+ /** Send a raw RFC 2822 message via SMTP for a given account.
2118
+ * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
2119
+ * same TCP byte-stream interface, no nodemailer dependency. */
2054
2120
  async sendRawViaSMTP(accountId, raw) {
2055
2121
  const settings = loadSettings();
2056
2122
  const account = settings.accounts.find(a => a.id === accountId);
2057
2123
  if (!account?.smtp)
2058
2124
  throw new Error(`No SMTP config for ${accountId}`);
2059
- // SMTP auth: use explicit SMTP credentials, fall back to IMAP credentials
2060
- let smtpAuth;
2125
+ const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
2126
+ const smtpHost = account.smtp.host || account.imap?.host;
2127
+ if (!smtpHost)
2128
+ throw new Error(`No SMTP host for ${accountId}`);
2129
+ // SMTP auth: explicit SMTP creds, fall back to IMAP creds for password auth.
2061
2130
  const smtpAuthType = account.smtp.auth || (account.imap?.password ? "password" : undefined);
2131
+ const smtpUser = account.smtp.user || account.imap?.user || account.email;
2132
+ let auth;
2062
2133
  if (smtpAuthType === "password") {
2063
- smtpAuth = {
2064
- user: account.smtp.user || account.imap?.user || account.email,
2065
- pass: account.smtp.password || account.imap?.password,
2066
- };
2134
+ const pass = account.smtp.password || account.imap?.password;
2135
+ if (!pass)
2136
+ throw new Error("SMTP password not configured");
2137
+ auth = { method: "PLAIN", user: smtpUser, pass };
2067
2138
  }
2068
2139
  else if (smtpAuthType === "oauth2") {
2069
- const accessToken = await this.getOAuthToken(accountId);
2070
- if (!accessToken)
2140
+ const token = await this.getOAuthToken(accountId);
2141
+ if (!token)
2071
2142
  throw new Error("OAuth token not available");
2072
- smtpAuth = { type: "OAuth2", user: account.smtp.user || account.imap?.user || account.email, accessToken };
2143
+ auth = { method: "XOAUTH2", user: smtpUser, token };
2073
2144
  }
2074
- const { createTransport } = await import("nodemailer");
2075
- const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
2076
- const transport = createTransport({
2077
- host: account.smtp.host || account.imap?.host,
2078
- port: smtpPort,
2079
- secure: smtpPort === SMTP_PORT_IMPLICIT_TLS, // 465 = implicit TLS, 587 = STARTTLS
2080
- auth: smtpAuth,
2081
- tls: { rejectUnauthorized: false },
2082
- });
2083
2145
  const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
2084
2146
  const toMatch = raw.match(/^To:\s*(.+)$/mi);
2085
2147
  const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
@@ -2097,17 +2159,33 @@ export class ImapManager extends EventEmitter {
2097
2159
  throw new Error("No recipients");
2098
2160
  // Dedup: skip if this Message-ID has already been sent. Prevents the
2099
2161
  // outbox from re-sending the same file across crash/restart cycles.
2100
- // Without this, a queued .ltr that was mid-delivery when mailx crashed
2101
- // would be re-sent on every startup until the rename loop completed.
2102
2162
  const messageId = messageIdMatch ? messageIdMatch[1] : "";
2103
2163
  if (messageId && this.db.hasSentMessage(messageId)) {
2104
2164
  console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
2105
- return; // caller will move the file to sent/ without re-sending
2165
+ return;
2106
2166
  }
2107
2167
  const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
2108
2168
  this.saveSendingCopy(accountId, rawToSend, "sent");
2109
- await transport.sendMail({ envelope: { from: sender, to: recipients }, raw: rawToSend });
2110
- // Record the successful send so future attempts dedupe against it.
2169
+ const smtp = new SmtpClient({
2170
+ host: smtpHost,
2171
+ port: smtpPort,
2172
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
2173
+ auth,
2174
+ localname: os.hostname(),
2175
+ }, this.transportFactory);
2176
+ try {
2177
+ await smtp.connect();
2178
+ const result = await smtp.sendMail({ from: sender, to: recipients }, rawToSend);
2179
+ if (result.rejected.length > 0) {
2180
+ console.log(` [smtp] ${accountId}: ${result.rejected.length} recipient(s) rejected: ${result.rejected.map(r => `${r.address} (${r.code})`).join(", ")}`);
2181
+ }
2182
+ }
2183
+ finally {
2184
+ try {
2185
+ await smtp.quit();
2186
+ }
2187
+ catch { /* ignore */ }
2188
+ }
2111
2189
  if (messageId) {
2112
2190
  this.db.recordSent(messageId, accountId, subjectMatch?.[1]?.trim() || "", recipients);
2113
2191
  }
@@ -2139,21 +2217,54 @@ export class ImapManager extends EventEmitter {
2139
2217
  catch { }
2140
2218
  return;
2141
2219
  }
2142
- const sendingFlag = `$Sending-${this.hostname}`;
2220
+ // Stale-claim recovery: if a peer (or our prior incarnation) crashed
2221
+ // mid-send, the $Sending-<host>-<ts> flag would otherwise pin the
2222
+ // message forever. Sweep flags older than STALE_CLAIM_MS first.
2223
+ const STALE_CLAIM_MS = 3600_000; // 1 hour — far longer than any reasonable SMTP send
2224
+ const nowSec = Math.floor(Date.now() / 1000);
2225
+ // Encode our claim with a seconds-since-epoch timestamp so peers
2226
+ // (and our own restart sweeper) can identify stale entries.
2227
+ const sendingFlag = `$Sending-${this.hostname}-${nowSec}`;
2143
2228
  for (const uid of uids) {
2144
2229
  // Check flags — skip if already being sent or permanently failed
2145
2230
  const flags = await client.getFlags(outboxFolder.path, uid);
2146
- if (flags.some((f) => f.startsWith("$Sending")))
2231
+ // Sweep stale claims. New form: $Sending-<host>-<sec>. Old form
2232
+ // ($Sending-<host>, no timestamp) is treated as stale on first
2233
+ // encounter — safe because if its owner is alive, it'll re-claim
2234
+ // with a fresh timestamped flag on its next tick.
2235
+ const claimFlags = flags.filter((f) => f.startsWith("$Sending"));
2236
+ for (const cf of claimFlags) {
2237
+ const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
2238
+ if (!m)
2239
+ continue;
2240
+ const tsSec = m[2] ? parseInt(m[2]) : 0;
2241
+ const ageSec = nowSec - tsSec;
2242
+ if (ageSec * 1000 > STALE_CLAIM_MS) {
2243
+ try {
2244
+ await client.removeFlags(outboxFolder.path, uid, [cf]);
2245
+ console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
2246
+ }
2247
+ catch { /* ignore */ }
2248
+ }
2249
+ }
2250
+ // Re-read flags after sweep
2251
+ const flagsNow = (claimFlags.length > 0)
2252
+ ? await client.getFlags(outboxFolder.path, uid)
2253
+ : flags;
2254
+ if (flagsNow.some((f) => f.startsWith("$Sending")))
2147
2255
  continue;
2148
- if (flags.includes("$PermanentFailure"))
2256
+ if (flagsNow.includes("$PermanentFailure"))
2149
2257
  continue;
2150
- if (flags.includes("$Failed")) {
2258
+ if (flagsNow.includes("$Failed")) {
2151
2259
  // Retry: remove failed flag
2152
2260
  await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
2153
2261
  }
2154
2262
  // Claim this message
2155
2263
  await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
2156
- // Re-check — did we win the race?
2264
+ // Re-check — did we win the race? (TOCTOU window: two devices
2265
+ // both reaching addFlags concurrently both see ≥2 sending flags
2266
+ // and both back off — fails safe; nobody sends this tick, next
2267
+ // tick one wins.)
2157
2268
  const flagsAfter = await client.getFlags(outboxFolder.path, uid);
2158
2269
  const sendingFlags = flagsAfter.filter((f) => f.startsWith("$Sending"));
2159
2270
  if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -13,8 +13,9 @@
13
13
  "@bobfrankston/mailx-settings": "file:../mailx-settings",
14
14
  "@bobfrankston/mailx-store": "file:../mailx-store",
15
15
  "@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
16
- "@bobfrankston/oauthsupport": "file:../../../../projects/oauth/oauthsupport",
17
- "nodemailer": "^7.0.0"
16
+ "@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
17
+ "@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
18
+ "@bobfrankston/oauthsupport": "file:../../../../projects/oauth/oauthsupport"
18
19
  },
19
20
  "repository": {
20
21
  "type": "git",
@@ -230,6 +230,9 @@ imapManager.on("syncProgress", (accountId, phase, progress) => {
230
230
  imapManager.on("folderCountsChanged", (accountId, counts) => {
231
231
  broadcast({ type: "folderCountsChanged", accountId, counts });
232
232
  });
233
+ imapManager.on("folderSynced", (accountId, folderId, syncedAt) => {
234
+ broadcast({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt }] });
235
+ });
233
236
  imapManager.on("syncError", (accountId, error) => {
234
237
  broadcast({ type: "error", message: `${accountId}: ${error}` });
235
238
  });
@@ -59,8 +59,10 @@ const SCHEMA = `
59
59
  CREATE INDEX IF NOT EXISTS idx_messages_message_id
60
60
  ON messages(message_id);
61
61
 
62
- CREATE INDEX IF NOT EXISTS idx_messages_thread_id
63
- ON messages(account_id, thread_id);
62
+ -- Note: idx_messages_thread_id is created by the addColumnIfMissing migration
63
+ -- in the constructor, AFTER thread_id is guaranteed to exist. Including it
64
+ -- here would crash startup on any pre-thread_id DB because exec(SCHEMA) runs
65
+ -- before the column-add migration.
64
66
 
65
67
  CREATE TABLE IF NOT EXISTS sent_log (
66
68
  message_id TEXT PRIMARY KEY,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -17,6 +17,8 @@ import { WebMailxService } from "./web-service.js";
17
17
  import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, getDeviceId, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
18
18
  import { GmailApiWebProvider } from "./gmail-api-web.js";
19
19
  import { ImapWebProvider } from "./imap-web-provider.js";
20
+ import { SmtpClient } from "@bobfrankston/smtp-direct";
21
+ import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
20
22
  // ── State ──
21
23
  let db;
22
24
  let bodyStore;
@@ -121,7 +123,12 @@ class AndroidSyncManager {
121
123
  return 1;
122
124
  return 0;
123
125
  });
124
- for (const folder of sorted.slice(0, 5)) {
126
+ // Sync every folder, not just the first five — the old slice(0, 5)
127
+ // meant subfolders past the cutoff (e.g. _spam, custom labels)
128
+ // never picked up moves made on other clients, and those moves
129
+ // also stayed visible in the source folder because reconcile
130
+ // (below in syncFolder) never ran for the target.
131
+ for (const folder of sorted) {
125
132
  try {
126
133
  await this.syncFolder(account.id, folder.id);
127
134
  }
@@ -185,6 +192,48 @@ class AndroidSyncManager {
185
192
  this.db.recalcFolderCounts(folderId);
186
193
  emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
187
194
  }
195
+ // Reconcile deletions — messages present locally but no longer on the
196
+ // server (moved away, deleted on another client). Without this, the
197
+ // Android client never drops removed rows: e.g., moves to _spam from
198
+ // another client showed up in _spam (next time it synced) but never
199
+ // disappeared from INBOX.
200
+ //
201
+ // Same safety guards as the desktop reconcile path:
202
+ // - Skip if the server list is empty but local has messages (likely
203
+ // a transient API failure that returned []).
204
+ // - Refuse to delete more than 50% of local in one pass — better to
205
+ // keep phantoms than to wipe a folder on a sync bug. Rebuild local
206
+ // cache fixes a stuck state.
207
+ try {
208
+ const serverUidsArr = await provider.getUids(folder.path);
209
+ const serverUids = new Set(serverUidsArr);
210
+ const localUids = this.db.getUidsForFolder(accountId, folderId);
211
+ if (serverUidsArr.length === 0 && localUids.length > 0) {
212
+ console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
213
+ }
214
+ else {
215
+ const toDelete = localUids.filter(uid => !serverUids.has(uid));
216
+ const RECONCILE_DELETE_THRESHOLD = 0.5;
217
+ if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
218
+ console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
219
+ }
220
+ else {
221
+ for (const uid of toDelete) {
222
+ this.db.deleteMessage(accountId, uid);
223
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
224
+ }
225
+ if (toDelete.length > 0) {
226
+ console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
227
+ this.db.recalcFolderCounts(folderId);
228
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
229
+ }
230
+ }
231
+ }
232
+ }
233
+ catch (e) {
234
+ console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
235
+ }
236
+ emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
188
237
  emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
189
238
  }
190
239
  storeProviderMessages(accountId, folderId, messages) {
@@ -286,8 +335,122 @@ class AndroidSyncManager {
286
335
  async undeleteMessage(accountId, uid, folderId) {
287
336
  this.db.queueSyncAction(accountId, "undelete", uid, folderId);
288
337
  }
289
- queueOutgoingLocal(accountId, _rawMessage) {
290
- console.log(`[send] Queued outgoing for ${accountId}`);
338
+ queueOutgoingLocal(accountId, rawMessage) {
339
+ // Two paths, both real (no stubs that pretend success — see programming.md
340
+ // rule "Stubs MUST NOT appear successful"):
341
+ // - Gmail accounts: POST to users.messages.send (Gmail handles SMTP +
342
+ // auto-files into Sent label).
343
+ // - Non-Gmail accounts: smtp-direct over BridgeTransport (mailxapi.tcp).
344
+ // Caller (web-service.send) is sync-returning; we kick off the network
345
+ // request and surface success/failure via events. Compose UI listens for
346
+ // sendError/sendComplete.
347
+ const provider = this.getProvider(accountId);
348
+ if (provider && typeof provider.sendRaw === "function") {
349
+ provider.sendRaw(rawMessage)
350
+ .then((result) => {
351
+ console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
352
+ emitEvent({ type: "sendComplete", accountId, messageId: result.id });
353
+ })
354
+ .catch((e) => {
355
+ console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
356
+ emitEvent({ type: "sendError", accountId, error: e.message });
357
+ });
358
+ return;
359
+ }
360
+ // Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the
361
+ // stored account JSON.
362
+ const accounts = db.getAccountConfigs();
363
+ const row = accounts.find(a => a.id === accountId);
364
+ if (!row) {
365
+ const e = "Unknown account";
366
+ console.error(`[send] ${accountId}: ${e}`);
367
+ emitEvent({ type: "sendError", accountId, error: e });
368
+ throw new Error(e);
369
+ }
370
+ let account;
371
+ try {
372
+ account = JSON.parse(row.configJson);
373
+ }
374
+ catch {
375
+ const e = "Account config malformed";
376
+ emitEvent({ type: "sendError", accountId, error: e });
377
+ throw new Error(e);
378
+ }
379
+ if (!account.smtp) {
380
+ const e = "No SMTP config for this account";
381
+ console.error(`[send] ${accountId}: ${e}`);
382
+ emitEvent({ type: "sendError", accountId, error: e });
383
+ throw new Error(e);
384
+ }
385
+ // Fire async — same pattern as Gmail path above.
386
+ this.sendViaSmtpDirect(accountId, account, rawMessage)
387
+ .then((result) => {
388
+ console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
389
+ emitEvent({ type: "sendComplete", accountId });
390
+ })
391
+ .catch((e) => {
392
+ console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
393
+ emitEvent({ type: "sendError", accountId, error: e.message });
394
+ });
395
+ }
396
+ /** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
397
+ async sendViaSmtpDirect(accountId, account, raw) {
398
+ const SMTP_PORT_STARTTLS = 587;
399
+ const SMTP_PORT_IMPLICIT_TLS = 465;
400
+ const smtp = account.smtp;
401
+ const smtpPort = smtp.port || SMTP_PORT_STARTTLS;
402
+ const smtpHost = smtp.host || account.imap?.host;
403
+ if (!smtpHost)
404
+ throw new Error("No SMTP host");
405
+ // Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider)
406
+ const smtpUser = smtp.user || account.imap?.user || account.email;
407
+ const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
408
+ let auth;
409
+ if (authType === "password") {
410
+ const pass = smtp.password || account.imap?.password;
411
+ if (!pass)
412
+ throw new Error("SMTP password not configured");
413
+ auth = { method: "PLAIN", user: smtpUser, pass };
414
+ }
415
+ else if (authType === "oauth2") {
416
+ const tp = this.tokenProviders.get(accountId);
417
+ if (!tp)
418
+ throw new Error("OAuth token provider not registered");
419
+ const token = await tp();
420
+ auth = { method: "XOAUTH2", user: smtpUser, token };
421
+ }
422
+ // Recipients from headers
423
+ const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
424
+ const toMatch = raw.match(/^To:\s*(.+)$/mi);
425
+ const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
426
+ const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
427
+ const fromMatch = raw.match(/^From:\s*(.+)$/mi);
428
+ const recipients = [
429
+ ...(toMatch ? parseAddrs(toMatch[1]) : []),
430
+ ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
431
+ ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
432
+ ];
433
+ const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
434
+ if (recipients.length === 0)
435
+ throw new Error("No recipients");
436
+ const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
437
+ const client = new SmtpClient({
438
+ host: smtpHost,
439
+ port: smtpPort,
440
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
441
+ auth,
442
+ localname: "mailx-android",
443
+ }, () => new BridgeTcpTransport());
444
+ try {
445
+ await client.connect();
446
+ return await client.sendMail({ from: sender, to: recipients }, rawToSend);
447
+ }
448
+ finally {
449
+ try {
450
+ await client.quit();
451
+ }
452
+ catch { /* ignore */ }
453
+ }
291
454
  }
292
455
  async saveDraft(_accountId, _raw, _prevUid, _draftId) {
293
456
  return null;
@@ -24,6 +24,13 @@ export declare class GmailApiWebProvider implements MailProvider {
24
24
  fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
25
25
  getUids(folder: string): Promise<number[]>;
26
26
  close(): Promise<void>;
27
+ /** Send an RFC 2822 message via Gmail API users.messages.send. The server
28
+ * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
29
+ * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
30
+ sendRaw(rawRfc822: string): Promise<{
31
+ id: string;
32
+ threadId: string;
33
+ }>;
27
34
  private folderToLabel;
28
35
  private formatDate;
29
36
  }
@@ -227,6 +227,18 @@ export class GmailApiWebProvider {
227
227
  return ids.map(idToUid);
228
228
  }
229
229
  async close() { }
230
+ /** Send an RFC 2822 message via Gmail API users.messages.send. The server
231
+ * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
232
+ * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
233
+ async sendRaw(rawRfc822) {
234
+ const b64 = btoa(unescape(encodeURIComponent(rawRfc822)))
235
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
236
+ const data = await this.apiFetch("/messages/send", {
237
+ method: "POST",
238
+ body: JSON.stringify({ raw: b64 }),
239
+ });
240
+ return { id: data.id, threadId: data.threadId };
241
+ }
230
242
  folderToLabel(path) {
231
243
  const lower = path.toLowerCase();
232
244
  if (lower === "inbox")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,6 +10,9 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "file:../mailx-types",
13
+ "@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
14
+ "@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
15
+ "@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
13
16
  "sql.js": "^1.14.1"
14
17
  },
15
18
  "repository": {
@@ -159,6 +159,13 @@ export type WsEvent = {
159
159
  total: number;
160
160
  unread: number;
161
161
  }>;
162
+ } | {
163
+ type: "folderSynced";
164
+ accountId: string;
165
+ entries: {
166
+ folderId: number;
167
+ syncedAt: number;
168
+ }[];
162
169
  } | {
163
170
  type: "syncProgress";
164
171
  accountId: string;
@@ -0,0 +1,4 @@
1
+ // Removed after one-shot smtp-direct test on 2026-04-13.
2
+ // Original sent a test message to test1@bob.ma via iecc submission.
3
+ // Result: 250 Accepted message qp 15437 (server queued for delivery).
4
+ // File overwritten because it had a plaintext password; safe to delete.