@bobfrankston/mailx 1.0.14 → 1.0.16

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/README.md CHANGED
@@ -1,7 +1,11 @@
1
- # mailx Email Client
1
+ # mailx -- Email Client
2
2
 
3
3
  A local-first email client with IMAP sync, full offline reading, and a standalone native app. Replaces Thunderbird/Outlook.
4
4
 
5
+ > **Disclaimer:** This is a personal project written for my own use. I provide it as-is with no promises of support, stability, or fitness for any particular purpose. Use at your own risk.
6
+
7
+ MIT License -- Copyright (c) 2026 Bob Frankston
8
+
5
9
  ## Quick Start
6
10
 
7
11
  ```bash
@@ -14,7 +18,7 @@ launch.ps1 -restart # Kill existing server and restart
14
18
 
15
19
  ## Setup
16
20
 
17
- ### 1. Config Pointer (~/.mailx/config.json)
21
+ ### 1. Config Pointer (~/.mailx/config.jsonc)
18
22
 
19
23
  Points to the shared settings file and local store:
20
24
 
@@ -28,7 +32,7 @@ Points to the shared settings file and local store:
28
32
  - **settingsPath** — Path to shared settings (can be on OneDrive/Dropbox for multi-machine sync)
29
33
  - **storePath** — Where cached message bodies are stored (local, not synced)
30
34
 
31
- If `config.json` doesn't exist, settings default to `~/.mailx/settings.jsonc`.
35
+ If `config.jsonc` doesn't exist, settings default to `~/.mailx/settings.jsonc`.
32
36
 
33
37
  ### 2. Settings File (settings.jsonc)
34
38
 
@@ -167,7 +171,7 @@ All data lives in `~/.mailx/` (e.g., `C:\Users\You\.mailx\`):
167
171
 
168
172
  | File | Shared? | Purpose |
169
173
  |------|---------|---------|
170
- | config.json | No | Points to shared settings dir + local overrides |
174
+ | config.jsonc | No | Points to shared settings dir + local overrides |
171
175
  | accounts.jsonc | Yes | IMAP/SMTP account configs |
172
176
  | preferences.jsonc | Yes | UI, sync, font settings |
173
177
  | allowlist.jsonc | Yes | Remote content sender/domain allow-list |
@@ -177,7 +181,7 @@ All data lives in `~/.mailx/` (e.g., `C:\Users\You\.mailx\`):
177
181
  | window.json | No | Window position (per machine) |
178
182
  | mailx-YYYY-MM-DD.log | No | Server log (auto-deleted after 7 days) |
179
183
 
180
- **Shared** files can live on OneDrive/Dropbox — `config.json` points to the shared directory. **Local** files stay on the machine.
184
+ **Shared** files can live on OneDrive/Dropbox — `config.jsonc` points to the shared directory. **Local** files stay on the machine.
181
185
 
182
186
  ### Safe to Delete
183
187
 
@@ -186,7 +190,7 @@ All data lives in `~/.mailx/` (e.g., `C:\Users\You\.mailx\`):
186
190
  - **mailx-*.log** — auto-cleaned after 7 days. Safe to delete anytime.
187
191
  - **window.json** — resets window position to default 1280×800.
188
192
 
189
- ### config.json
193
+ ### config.jsonc
190
194
 
191
195
  ```json
192
196
  {
@@ -200,7 +204,7 @@ All data lives in `~/.mailx/` (e.g., `C:\Users\You\.mailx\`):
200
204
  - **storePath** — where cached .eml files are stored
201
205
  - **historyDays** — per-machine override for sync history (shared default is 30)
202
206
 
203
- If `config.json` doesn't exist, all settings default to `~/.mailx/`.
207
+ If `config.jsonc` doesn't exist, all settings default to `~/.mailx/`.
204
208
 
205
209
  ## Architecture
206
210
 
package/client/app.js CHANGED
@@ -13,6 +13,8 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
13
13
  currentFolderSpecialUse = specialUse;
14
14
  currentAccountId = accountId;
15
15
  currentFolderId = folderId;
16
+ if (searchInput)
17
+ searchInput.value = "";
16
18
  loadMessages(accountId, folderId, 1, specialUse);
17
19
  document.title = `mailx - ${folderName}`;
18
20
  }, () => {
@@ -210,15 +212,28 @@ document.getElementById("btn-forward")?.addEventListener("click", () => openComp
210
212
  let searchTimeout;
211
213
  const searchInput = document.getElementById("search-input");
212
214
  const searchScope = document.getElementById("search-scope");
213
- function doSearch() {
215
+ function doSearch(immediate = false) {
214
216
  const query = searchInput.value.trim();
215
- if (query.length < 2) {
216
- if (query.length === 0)
217
- reloadCurrentFolder();
217
+ if (query.length === 0) {
218
+ reloadCurrentFolder();
218
219
  return;
219
220
  }
221
+ if (query.length < 2 && !immediate)
222
+ return;
220
223
  const scope = searchScope?.value || "all";
221
- // For "current" and "server" scopes, pass the active folder context
224
+ // "This folder" scope: instant client-side filter on debounce, server search on Enter
225
+ if (scope === "current" && !immediate) {
226
+ // Client-side filter of visible rows
227
+ const body = document.getElementById("ml-body");
228
+ if (body) {
229
+ const lower = query.toLowerCase();
230
+ for (const row of body.querySelectorAll(".ml-row")) {
231
+ const text = row.textContent?.toLowerCase() || "";
232
+ row.classList.toggle("filter-hidden", !text.includes(lower));
233
+ }
234
+ }
235
+ return;
236
+ }
222
237
  loadSearchResults(query, scope, currentAccountId, currentFolderId);
223
238
  document.title = `mailx - Search: ${query}`;
224
239
  }
@@ -227,15 +242,19 @@ let currentAccountId = "";
227
242
  let currentFolderId = 0;
228
243
  searchInput?.addEventListener("input", () => {
229
244
  clearTimeout(searchTimeout);
230
- searchTimeout = setTimeout(doSearch, 400);
245
+ searchTimeout = setTimeout(() => doSearch(false), 300);
231
246
  });
232
247
  searchInput?.addEventListener("keydown", (e) => {
233
248
  if (e.key === "Enter") {
234
249
  clearTimeout(searchTimeout);
235
- doSearch();
250
+ doSearch(true);
236
251
  }
237
252
  if (e.key === "Escape") {
238
253
  searchInput.value = "";
254
+ // Clear any client-side filters
255
+ const body = document.getElementById("ml-body");
256
+ if (body)
257
+ body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
239
258
  reloadCurrentFolder();
240
259
  }
241
260
  });
@@ -486,7 +505,8 @@ fetch("/api/version").then(r => r.json()).then(d => {
486
505
  startupStatus.textContent = "Server offline — start with: node packages/mailx-server/index.js";
487
506
  }
488
507
  });
489
- // ── Sync pending indicator ──
508
+ // ── Sync pending indicator + server health check ──
509
+ let serverDown = false;
490
510
  setInterval(async () => {
491
511
  try {
492
512
  const res = await fetch("/api/sync/pending");
@@ -498,8 +518,25 @@ setInterval(async () => {
498
518
  el.textContent = data.pending > 0 ? `↻ ${data.pending} pending` : "";
499
519
  el.style.color = data.pending > 0 ? "oklch(0.75 0.15 60)" : "";
500
520
  }
521
+ // Server is back — reload if it was down
522
+ if (serverDown) {
523
+ serverDown = false;
524
+ const statusEl = document.getElementById("status-sync");
525
+ if (statusEl)
526
+ statusEl.textContent = "Server reconnected";
527
+ location.reload();
528
+ }
529
+ }
530
+ catch {
531
+ if (!serverDown) {
532
+ serverDown = true;
533
+ const statusEl = document.getElementById("status-sync");
534
+ if (statusEl) {
535
+ statusEl.textContent = "SERVER OFFLINE";
536
+ statusEl.style.color = "oklch(0.65 0.2 25)";
537
+ }
538
+ }
501
539
  }
502
- catch { /* offline */ }
503
540
  }, 5000);
504
541
  console.log("mailx client initialized, location:", location.href);
505
542
  // Diagnostic: test API connectivity (helps debug WebView2 blank screen)
@@ -3,11 +3,6 @@
3
3
  * Loads more messages on scroll.
4
4
  */
5
5
  import { getMessages, getUnifiedInbox, searchMessages } from "../lib/api-client.js";
6
- function clearFilter() {
7
- const f = document.getElementById("ml-filter-input");
8
- if (f)
9
- f.value = "";
10
- }
11
6
  /** Clear the message viewer when no message is selected */
12
7
  function clearViewer() {
13
8
  const bodyEl = document.getElementById("mv-body");
@@ -64,26 +59,6 @@ const dateFmt = { year: "numeric", month: "short", day: "numeric", hour: "2-digi
64
59
  const dateFmtSameYear = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
65
60
  export function initMessageList(handler) {
66
61
  onMessageSelect = handler;
67
- // Client-side filter
68
- const filterInput = document.getElementById("ml-filter-input");
69
- if (filterInput) {
70
- filterInput.addEventListener("input", () => {
71
- const query = filterInput.value.toLowerCase();
72
- const body = document.getElementById("ml-body");
73
- if (!body)
74
- return;
75
- for (const row of body.querySelectorAll(".ml-row")) {
76
- const text = row.textContent?.toLowerCase() || "";
77
- row.classList.toggle("filter-hidden", query.length > 0 && !text.includes(query));
78
- }
79
- });
80
- filterInput.addEventListener("keydown", (e) => {
81
- if (e.key === "Escape") {
82
- filterInput.value = "";
83
- filterInput.dispatchEvent(new Event("input"));
84
- }
85
- });
86
- }
87
62
  // Infinite scroll
88
63
  const body = document.getElementById("ml-body");
89
64
  if (body) {
@@ -97,21 +72,20 @@ export function initMessageList(handler) {
97
72
  });
98
73
  }
99
74
  }
100
- /** Reload the currently displayed folder */
75
+ /** Reload the currently displayed folder (preserves current selection) */
101
76
  export function reloadCurrentFolder() {
102
77
  if (searchMode) {
103
78
  loadSearchResults(currentSearchQuery);
104
79
  }
105
80
  else if (unifiedMode) {
106
- loadUnifiedInbox();
81
+ loadUnifiedInbox(false);
107
82
  }
108
83
  else if (currentAccountId && currentFolderId) {
109
- loadMessages(currentAccountId, currentFolderId);
84
+ loadMessages(currentAccountId, currentFolderId, 1, "", false);
110
85
  }
111
86
  }
112
87
  /** Load unified inbox (all accounts) */
113
- export async function loadUnifiedInbox() {
114
- clearFilter();
88
+ export async function loadUnifiedInbox(autoSelect = true) {
115
89
  unifiedMode = true;
116
90
  currentPage = 1;
117
91
  totalMessages = 0;
@@ -129,9 +103,11 @@ export async function loadUnifiedInbox() {
129
103
  }
130
104
  body.innerHTML = "";
131
105
  appendMessages(body, "", result.items);
132
- const firstRow = body.querySelector(".ml-row");
133
- if (firstRow)
134
- firstRow.click();
106
+ if (autoSelect) {
107
+ const firstRow = body.querySelector(".ml-row");
108
+ if (firstRow)
109
+ firstRow.click();
110
+ }
135
111
  }
136
112
  catch (e) {
137
113
  if (e.name === "AbortError")
@@ -141,7 +117,6 @@ export async function loadUnifiedInbox() {
141
117
  }
142
118
  /** Load search results */
143
119
  export async function loadSearchResults(query, scope = "all", accountId = "", folderId = 0) {
144
- clearFilter();
145
120
  searchMode = true;
146
121
  unifiedMode = false;
147
122
  currentSearchQuery = query;
@@ -193,8 +168,7 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
193
168
  body.innerHTML = `<div class="ml-empty">Search error: ${e.message}</div>`;
194
169
  }
195
170
  }
196
- export async function loadMessages(accountId, folderId, page = 1, specialUse = "") {
197
- clearFilter();
171
+ export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
198
172
  searchMode = false;
199
173
  unifiedMode = false;
200
174
  showToInsteadOfFrom = ["sent", "drafts", "outbox"].includes(specialUse) ||
@@ -221,10 +195,12 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
221
195
  }
222
196
  body.innerHTML = "";
223
197
  appendMessages(body, accountId, result.items);
224
- // Auto-select first message
225
- const firstRow = body.querySelector(".ml-row");
226
- if (firstRow)
227
- firstRow.click();
198
+ // Auto-select first message only on explicit folder navigation, not sync reload
199
+ if (autoSelect) {
200
+ const firstRow = body.querySelector(".ml-row");
201
+ if (firstRow)
202
+ firstRow.click();
203
+ }
228
204
  }
229
205
  catch (e) {
230
206
  if (e.name === "AbortError")
@@ -6,13 +6,16 @@ import { getMessage, updateFlags } from "../lib/api-client.js";
6
6
  let currentMessage = null;
7
7
  let currentAccountId = "";
8
8
  let showMessageGeneration = 0; // Cancel stale fetches
9
+ let retryCount = 0;
9
10
  export function getCurrentMessage() {
10
11
  if (!currentMessage)
11
12
  return null;
12
13
  return { accountId: currentAccountId, message: currentMessage };
13
14
  }
14
- export async function showMessage(accountId, uid, folderId, specialUse) {
15
+ export async function showMessage(accountId, uid, folderId, specialUse, isRetry = false) {
15
16
  const gen = ++showMessageGeneration;
17
+ if (!isRetry)
18
+ retryCount = 0;
16
19
  const headerEl = document.getElementById("mv-header");
17
20
  const bodyEl = document.getElementById("mv-body");
18
21
  const attEl = document.getElementById("mv-attachments");
@@ -42,13 +45,12 @@ export async function showMessage(accountId, uid, folderId, specialUse) {
42
45
  headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
43
46
  // Unsubscribe button (upper right of header)
44
47
  const unsubBtn = document.getElementById("mv-unsubscribe");
45
- const headerUnsub = msg.listUnsubscribe || "";
46
- const headerUnsubUrl = headerUnsub.match(/<(https?:\/\/[^>]+)>/)?.[1]
47
- || headerUnsub.match(/<(mailto:[^>]+)>/)?.[1] || "";
48
+ // listUnsubscribe is now a clean URL (https:// or mailto:) from the server
49
+ const unsubUrl = msg.listUnsubscribe || "";
48
50
  if (unsubBtn) {
49
- if (headerUnsubUrl) {
51
+ if (unsubUrl) {
50
52
  unsubBtn.hidden = false;
51
- unsubBtn.href = headerUnsubUrl;
53
+ unsubBtn.href = unsubUrl;
52
54
  unsubBtn.target = "_blank";
53
55
  unsubBtn.rel = "noopener noreferrer";
54
56
  }
@@ -202,7 +204,8 @@ export async function showMessage(accountId, uid, folderId, specialUse) {
202
204
  else if (msg.bodyText) {
203
205
  const pre = document.createElement("pre");
204
206
  pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
205
- pre.textContent = msg.bodyText;
207
+ // Auto-linkify URLs in plain text
208
+ pre.innerHTML = linkifyText(msg.bodyText);
206
209
  bodyEl.appendChild(pre);
207
210
  }
208
211
  else {
@@ -221,7 +224,17 @@ export async function showMessage(accountId, uid, folderId, specialUse) {
221
224
  }
222
225
  }
223
226
  catch (e) {
224
- bodyEl.innerHTML = `<div class="mv-empty">Error: ${e.message}</div>`;
227
+ const err = e.message || "Unknown error";
228
+ console.error("showMessage error:", e);
229
+ if (retryCount < 3) {
230
+ retryCount++;
231
+ bodyEl.innerHTML = `<div class="mv-empty">Loading failed: ${err} — retrying (${retryCount}/3)...</div>`;
232
+ setTimeout(() => { if (gen === showMessageGeneration)
233
+ showMessage(accountId, uid, folderId, specialUse, true); }, 3000);
234
+ }
235
+ else {
236
+ bodyEl.innerHTML = `<div class="mv-empty">Failed to load message: ${err}</div>`;
237
+ }
225
238
  }
226
239
  }
227
240
  function formatAddr(addr) {
@@ -229,6 +242,13 @@ function formatAddr(addr) {
229
242
  return `${addr.name} <${addr.address}>`;
230
243
  return addr.address;
231
244
  }
245
+ /** Convert plain text URLs into clickable links, escaping HTML */
246
+ function linkifyText(text) {
247
+ // Escape HTML first
248
+ const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
249
+ // Then linkify URLs
250
+ return escaped.replace(/(https?:\/\/[^\s<>"')\]]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
251
+ }
232
252
  function escapeText(s) {
233
253
  const div = document.createElement("div");
234
254
  div.textContent = s;
package/client/index.html CHANGED
@@ -73,9 +73,6 @@
73
73
 
74
74
  <main class="main-area">
75
75
  <section class="message-list" id="message-list">
76
- <div class="ml-filter">
77
- <input type="text" id="ml-filter-input" placeholder="Filter..." autocomplete="off">
78
- </div>
79
76
  <div class="ml-header">
80
77
  <span class="ml-col ml-col-flag"></span>
81
78
  <span class="ml-col ml-col-from" data-sort="from">From</span>
@@ -19,10 +19,19 @@ function newMessageListSignal() {
19
19
  return messageListAbort.signal;
20
20
  }
21
21
  async function api(path, options) {
22
- const res = await fetch(`/api${path}`, {
23
- headers: { "Content-Type": "application/json" },
24
- ...options
25
- });
22
+ let res;
23
+ try {
24
+ res = await fetch(`/api${path}`, {
25
+ headers: { "Content-Type": "application/json" },
26
+ ...options
27
+ });
28
+ }
29
+ catch (e) {
30
+ // Network error — server is down
31
+ if (e.name === "AbortError")
32
+ throw e;
33
+ throw new Error("Server offline — restart with launch.ps1");
34
+ }
26
35
  if (!res.ok) {
27
36
  const err = await res.json().catch(() => ({ error: res.statusText }));
28
37
  throw new Error(err.error || res.statusText);
@@ -92,7 +92,7 @@
92
92
  background: var(--color-bg-surface);
93
93
  color: var(--color-text);
94
94
  font-size: var(--font-size-sm);
95
- width: 200px;
95
+ width: 500px;
96
96
 
97
97
  &::placeholder { color: var(--color-text-muted); }
98
98
  &:focus { outline: 1px solid var(--color-accent); border-color: var(--color-accent); }
@@ -218,7 +218,7 @@
218
218
  .message-list {
219
219
  display: grid;
220
220
  grid-template-columns: 1.2em minmax(120px, 200px) auto 1fr;
221
- grid-template-rows: auto auto 1fr;
221
+ grid-template-rows: auto 1fr;
222
222
  column-gap: var(--gap-sm);
223
223
  overflow: hidden;
224
224
  border-right: 1px solid var(--color-border);
@@ -244,28 +244,6 @@
244
244
  font-size: var(--font-size-sm);
245
245
  }
246
246
 
247
- .ml-filter {
248
- grid-column: 1 / -1;
249
- padding: 2px var(--gap-xs);
250
- border-bottom: 1px solid var(--color-border);
251
- background: var(--color-bg-surface);
252
- }
253
-
254
- .ml-filter input {
255
- width: 100%;
256
- padding: 2px var(--gap-sm);
257
- border: 1px solid var(--color-border);
258
- border-radius: var(--radius-sm);
259
- background: var(--color-bg);
260
- color: var(--color-text);
261
- font-size: var(--font-size-sm);
262
- outline: none;
263
- }
264
-
265
- .ml-filter input:focus {
266
- border-color: var(--color-accent);
267
- }
268
-
269
247
  .ml-row.filter-hidden { display: none; }
270
248
 
271
249
  .ml-header {
Binary file
@@ -1,81 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Post-install script to set executable permissions on Linux/Mac binaries
4
- * and check for required system dependencies (webkit2gtk on Linux).
3
+ * mailx postinstall sets binary permissions on Linux/Mac.
4
+ * Tries shared rust-builder; falls back to inline logic if not available.
5
5
  */
6
-
7
- import fs from "fs";
8
- import path from "path";
9
- import { execSync } from "child_process";
10
-
11
- function detectDistro() {
12
- if (!fs.existsSync("/etc/os-release")) return "unknown";
13
- try {
14
- const osRelease = fs.readFileSync("/etc/os-release", "utf8").toLowerCase();
15
- if (osRelease.includes("ubuntu") || osRelease.includes("debian")) return "debian";
16
- if (osRelease.includes("fedora") || osRelease.includes("rhel") || osRelease.includes("centos")) return "fedora";
17
- if (osRelease.includes("arch")) return "arch";
18
- return "unknown";
19
- } catch { return "unknown"; }
20
- }
21
-
22
- function showDependencyHelp(distro) {
23
- console.warn("\n Missing system dependencies for mailx native launcher.");
24
- console.warn(" Install required libraries:\n");
25
- switch (distro) {
26
- case "debian": console.warn(" sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0\n"); break;
27
- case "fedora": console.warn(" sudo dnf install webkit2gtk4.1 gtk3\n"); break;
28
- case "arch": console.warn(" sudo pacman -S webkit2gtk-4.1 gtk3\n"); break;
29
- default: console.warn(" Install webkit2gtk-4.1 and gtk3 for your distribution\n");
6
+ try {
7
+ const { runPostinstall } = await import("@bobfrankston/rust-builder/postinstall");
8
+ const path = await import("path");
9
+ const { fileURLToPath } = await import("url");
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ runPostinstall({
13
+ binaryName: "mailx-app",
14
+ binDir: path.join(__dirname, "..", "bin"),
15
+ binaries: {
16
+ win32: "mailx-app.exe",
17
+ darwin: "mailx-app",
18
+ darwinArm64: "mailx-app-arm64",
19
+ linux: "mailx-app-linux",
20
+ linuxArm64: "mailx-app-linux-aarch64",
21
+ },
22
+ });
23
+ } catch {
24
+ // rust-builder not available (e.g., local dev with file: deps)
25
+ // On Windows, nothing to do. On Linux/Mac, try chmod directly.
26
+ if (process.platform !== "win32") {
27
+ const fs = await import("fs");
28
+ const path = await import("path");
29
+ const { fileURLToPath } = await import("url");
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const arch = process.arch;
32
+ const name = process.platform === "darwin"
33
+ ? (arch === "arm64" ? "mailx-app-arm64" : "mailx-app")
34
+ : (arch === "arm64" ? "mailx-app-linux-aarch64" : "mailx-app-linux");
35
+ const bin = path.join(__dirname, "..", "bin", name);
36
+ try { fs.chmodSync(bin, 0o755); } catch { /* binary may not exist */ }
30
37
  }
31
38
  }
32
-
33
- function main() {
34
- if (process.platform === "win32") return;
35
-
36
- const binDir = path.join(import.meta.dirname, "..", "bin");
37
- const arch = process.arch;
38
-
39
- // Binary naming convention (matches msger pattern):
40
- // Windows: mailx-app.exe
41
- // macOS x64: mailx-app
42
- // macOS arm64: mailx-app-arm64
43
- // Linux x64: mailx-app-linux
44
- // Linux arm64: mailx-app-linux-aarch64
45
- let binaryName;
46
- if (process.platform === "darwin") {
47
- binaryName = arch === "arm64" ? "mailx-app-arm64" : "mailx-app";
48
- } else {
49
- binaryName = arch === "arm64" ? "mailx-app-linux-aarch64" : "mailx-app-linux";
50
- }
51
-
52
- const binaryPath = path.join(binDir, binaryName);
53
-
54
- if (!fs.existsSync(binaryPath)) {
55
- console.warn(` mailx native launcher not available for ${process.platform} ${arch}`);
56
- console.warn(" mailx will run in browser mode (server + open browser)");
57
- return;
58
- }
59
-
60
- // Set executable permissions
61
- try {
62
- fs.chmodSync(binaryPath, 0o755);
63
- console.log(` Set execute permissions on ${binaryName}`);
64
- } catch (e) {
65
- console.warn(` Could not chmod ${binaryPath}: ${e.message}`);
66
- }
67
-
68
- // Check system dependencies on Linux
69
- if (process.platform === "linux") {
70
- try {
71
- const ldd = execSync(`ldd "${binaryPath}" 2>&1`, { encoding: "utf8" });
72
- if (ldd.includes("not found")) {
73
- showDependencyHelp(detectDistro());
74
- }
75
- } catch {
76
- console.warn(" Could not check system dependencies");
77
- }
78
- }
79
- }
80
-
81
- main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -23,6 +23,7 @@
23
23
  "@bobfrankston/iflow": "^1.0.2",
24
24
  "@bobfrankston/miscinfo": "^1.0.5",
25
25
  "@bobfrankston/oauthsupport": "^1.0.10",
26
+ "@bobfrankston/rust-builder": "^0.1.1",
26
27
  "mailparser": "^3.7.2",
27
28
  "quill": "^2.0.3",
28
29
  "express": "^4.21.0",
@@ -43,6 +44,9 @@
43
44
  ],
44
45
  "author": "Bob Frankston",
45
46
  "license": "MIT",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
46
50
  "repository": {
47
51
  "type": "git",
48
52
  "url": "https://github.com/BobFrankston/mailx.git"
@@ -51,6 +55,7 @@
51
55
  "@bobfrankston/iflow": "file:../MailApps/iflow",
52
56
  "@bobfrankston/miscinfo": "file:../../projects/npm/miscinfo",
53
57
  "@bobfrankston/oauthsupport": "file:../../projects/oauth/oauthsupport",
58
+ "@bobfrankston/rust-builder": "file:../../utils/rust-builder",
54
59
  "mailparser": "^3.7.2",
55
60
  "quill": "^2.0.3",
56
61
  "express": "^4.21.0",
@@ -90,7 +90,15 @@ export function createApiRouter(db, imapManager) {
90
90
  let hasRemoteContent = false;
91
91
  let attachments = [];
92
92
  const t0 = Date.now();
93
- const raw = await imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
93
+ let raw = null;
94
+ try {
95
+ raw = await imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
96
+ }
97
+ catch (fetchErr) {
98
+ console.error(` [fetch] Failed UID ${uid}: ${fetchErr.message || fetchErr}`);
99
+ // Return envelope with error instead of 500 — user can still see headers
100
+ return res.json({ ...envelope, bodyHtml: "", bodyText: `[Message body unavailable: ${fetchErr.message || "IMAP connection failed"}]`, hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: "" });
101
+ }
94
102
  const t1 = Date.now();
95
103
  if (raw) {
96
104
  const parsed = await simpleParser(raw);
@@ -135,20 +143,31 @@ export function createApiRouter(db, imapManager) {
135
143
  if (raw) {
136
144
  const parsed2 = await simpleParser(raw);
137
145
  const hdr = (key) => {
138
- const v = parsed2.headers.get(key);
146
+ let v = parsed2.headers.get(key);
139
147
  if (!v)
140
148
  return "";
149
+ if (Array.isArray(v))
150
+ v = v[0];
141
151
  if (typeof v === "string")
142
152
  return v;
143
- if (typeof v === "object" && "text" in v)
144
- return v.text || "";
145
- if (typeof v === "object" && "value" in v)
146
- return String(v.value);
153
+ if (typeof v === "object" && v !== null) {
154
+ if ("text" in v)
155
+ return v.text || "";
156
+ if ("value" in v)
157
+ return String(v.value);
158
+ if ("address" in v)
159
+ return v.address || "";
160
+ }
147
161
  return String(v);
148
162
  };
149
163
  deliveredTo = hdr("delivered-to");
150
164
  returnPath = hdr("return-path").replace(/[<>]/g, "");
151
- listUnsubscribe = hdr("list-unsubscribe");
165
+ // mailparser merges List-* headers into a "list" object
166
+ const listHeaders = parsed2.headers.get("list");
167
+ if (listHeaders?.unsubscribe) {
168
+ const unsub = listHeaders.unsubscribe;
169
+ listUnsubscribe = unsub.url || (unsub.mail ? `mailto:${unsub.mail}` : "");
170
+ }
152
171
  }
153
172
  const message = {
154
173
  ...envelope,
@@ -397,6 +416,18 @@ export function createApiRouter(db, imapManager) {
397
416
  res.status(500).json({ error: e.message });
398
417
  }
399
418
  });
419
+ // ── Direct IMAP delete (for messages not in local DB) ──
420
+ router.delete("/imap/:accountId/:folderPath/:uid", async (req, res) => {
421
+ try {
422
+ const { accountId, uid } = req.params;
423
+ const folderPath = decodeURIComponent(req.params.folderPath);
424
+ await imapManager.deleteOnServer(accountId, folderPath, Number(uid));
425
+ res.json({ ok: true });
426
+ }
427
+ catch (e) {
428
+ res.status(500).json({ error: e.message });
429
+ }
430
+ });
400
431
  // ── Drafts ──
401
432
  router.post("/draft", async (req, res) => {
402
433
  try {
@@ -154,20 +154,31 @@ export async function getMessage(params) {
154
154
  }));
155
155
  // Extract useful headers for the UI
156
156
  const hdr = (key) => {
157
- const v = parsed.headers.get(key);
157
+ let v = parsed.headers.get(key);
158
158
  if (!v)
159
159
  return "";
160
+ if (Array.isArray(v))
161
+ v = v[0];
160
162
  if (typeof v === "string")
161
163
  return v;
162
- if (typeof v === "object" && "text" in v)
163
- return v.text || "";
164
- if (typeof v === "object" && "value" in v)
165
- return String(v.value);
164
+ if (typeof v === "object" && v !== null) {
165
+ if ("text" in v)
166
+ return v.text || "";
167
+ if ("value" in v)
168
+ return String(v.value);
169
+ if ("address" in v)
170
+ return v.address || "";
171
+ }
166
172
  return String(v);
167
173
  };
168
174
  deliveredTo = hdr("delivered-to");
169
175
  returnPath = hdr("return-path").replace(/[<>]/g, "");
170
- listUnsubscribe = hdr("list-unsubscribe");
176
+ // mailparser merges List-* headers into a "list" object
177
+ const listHeaders = parsed.headers.get("list");
178
+ if (listHeaders?.unsubscribe) {
179
+ const unsub = listHeaders.unsubscribe;
180
+ listUnsubscribe = unsub.url || (unsub.mail ? `mailto:${unsub.mail}` : "");
181
+ }
171
182
  }
172
183
  if (bodyHtml && !allowRemote) {
173
184
  const allowList = loadAllowlist();
@@ -30,6 +30,8 @@ export declare class ImapManager extends EventEmitter {
30
30
  constructor(db: MailxDB);
31
31
  /** Get OAuth access token for an account (for SMTP auth) */
32
32
  getOAuthToken(accountId: string): Promise<string | null>;
33
+ /** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
34
+ deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
33
35
  /** Search messages on the IMAP server — returns matching UIDs */
34
36
  searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
35
37
  /** Create a fresh ImapClient for an account (disposable, single-use) */
@@ -62,6 +62,20 @@ export class ImapManager extends EventEmitter {
62
62
  return null;
63
63
  return config.tokenProvider();
64
64
  }
65
+ /** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
66
+ async deleteOnServer(accountId, folderPath, uid) {
67
+ const client = this.createClient(accountId);
68
+ try {
69
+ await client.deleteMessageByUid(folderPath, uid);
70
+ console.log(` Deleted UID ${uid} from ${folderPath} on server`);
71
+ }
72
+ finally {
73
+ try {
74
+ await client.logout();
75
+ }
76
+ catch { /* ignore */ }
77
+ }
78
+ }
65
79
  /** Search messages on the IMAP server — returns matching UIDs */
66
80
  async searchOnServer(accountId, mailboxPath, criteria) {
67
81
  const client = this.createClient(accountId);
@@ -281,9 +295,12 @@ export class ImapManager extends EventEmitter {
281
295
  for (const [accountId] of this.configs) {
282
296
  let client = null;
283
297
  try {
284
- // Fresh client for folder list
298
+ // Fresh client for folder list (30s timeout)
285
299
  client = this.createClient(accountId);
286
- const folders = await this.syncFolders(accountId, client);
300
+ const folders = await Promise.race([
301
+ this.syncFolders(accountId, client),
302
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Folder list timeout (30s)")), 30000))
303
+ ]);
287
304
  await client.logout();
288
305
  client = null;
289
306
  // INBOX first so it's available fastest
@@ -294,11 +311,14 @@ export class ImapManager extends EventEmitter {
294
311
  return 1;
295
312
  return 0;
296
313
  });
297
- // Fresh client per folder — IMAP connections drop mid-sync on large accounts
314
+ // Fresh client per folder with 60s timeout — IMAP connections can hang
298
315
  for (const folder of folders) {
299
316
  try {
300
317
  client = this.createClient(accountId);
301
- await this.syncFolder(accountId, folder.id, client);
318
+ await Promise.race([
319
+ this.syncFolder(accountId, folder.id, client),
320
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
321
+ ]);
302
322
  await client.logout();
303
323
  client = null;
304
324
  }
@@ -233,6 +233,14 @@ process.on("SIGTERM", shutdown);
233
233
  process.on("unhandledRejection", (err) => {
234
234
  console.error("Unhandled rejection:", err?.message || err);
235
235
  });
236
+ process.on("uncaughtException", (err) => {
237
+ console.error("FATAL uncaught exception:", err.message);
238
+ console.error(err.stack);
239
+ // Don't exit — let node --watch handle restart
240
+ });
241
+ process.on("exit", (code) => {
242
+ console.log(`Process exiting with code ${code}`);
243
+ });
236
244
  // ── Entry Point ──
237
245
  await start();
238
246
  export { app, start };