@bobfrankston/mailx 1.0.13 → 1.0.15

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.
Files changed (87) hide show
  1. package/README.md +11 -7
  2. package/bin/mailx.js +8 -2
  3. package/client/app.js +39 -17
  4. package/client/components/message-list.js +46 -42
  5. package/client/components/message-viewer.js +16 -7
  6. package/client/index.html +20 -18
  7. package/client/lib/api-client.js +11 -2
  8. package/client/styles/components.css +20 -25
  9. package/launcher/bin/mailx-app-linux +0 -0
  10. package/launcher/bin/mailx-app.exe +0 -0
  11. package/launcher/builder/build-config.json +11 -0
  12. package/launcher/builder/postinstall.js +21 -0
  13. package/package.json +4 -4
  14. package/packages/mailx-api/index.js +84 -10
  15. package/packages/mailx-core/index.js +17 -6
  16. package/packages/mailx-imap/index.d.ts +4 -0
  17. package/packages/mailx-imap/index.js +37 -4
  18. package/packages/mailx-store/db.d.ts +1 -1
  19. package/packages/mailx-store/db.js +14 -4
  20. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Breadcrumbs +0 -0
  21. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Crashpad/metadata +0 -0
  22. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Crashpad/settings.dat +0 -0
  23. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Crashpad/throttle_store.dat +0 -1
  24. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/CrashpadMetrics-active.pma +0 -0
  25. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/BrowsingTopicsSiteData +0 -0
  26. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Cache/No_Vary_Search/journal.baj +0 -1
  27. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/DIPS +0 -0
  28. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/DashTrackerDatabase +0 -0
  29. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/EdgeJourneys/EdgeJourneys.db +0 -0
  30. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension Rules/LOCK +0 -0
  31. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension Rules/LOG +0 -3
  32. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension Rules/MANIFEST-000001 +0 -0
  33. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension Scripts/LOCK +0 -0
  34. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension Scripts/LOG +0 -3
  35. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension Scripts/MANIFEST-000001 +0 -0
  36. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension State/LOCK +0 -0
  37. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension State/LOG +0 -3
  38. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Extension State/MANIFEST-000001 +0 -0
  39. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/ExtensionActivityComp +0 -0
  40. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/ExtensionActivityEdge +0 -0
  41. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Favicons +0 -0
  42. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/History +0 -0
  43. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/History-journal +0 -0
  44. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/IndexedDB/devtools_devtools_0.indexeddb.leveldb/LOCK +0 -0
  45. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/IndexedDB/devtools_devtools_0.indexeddb.leveldb/LOG +0 -3
  46. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/IndexedDB/devtools_devtools_0.indexeddb.leveldb/MANIFEST-000001 +0 -0
  47. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Local Storage/leveldb/LOCK +0 -0
  48. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Local Storage/leveldb/LOG +0 -3
  49. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Local Storage/leveldb/MANIFEST-000001 +0 -0
  50. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Login Data +0 -0
  51. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Login Data For Account +0 -0
  52. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Network/Cookies +0 -0
  53. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Network/Reporting and NEL +0 -0
  54. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Network/Trust Tokens +0 -0
  55. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Network Action Predictor +0 -0
  56. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Preferences +0 -1
  57. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Safe Browsing Network/Safe Browsing Cookies +0 -0
  58. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/ServerCertificate +0 -0
  59. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Session Storage/LOCK +0 -0
  60. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Session Storage/LOG +0 -3
  61. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Session Storage/MANIFEST-000001 +0 -0
  62. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Shared Dictionary/db +0 -0
  63. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/SharedStorage +0 -0
  64. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Shortcuts +0 -0
  65. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Site Characteristics Database/LOCK +0 -0
  66. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Site Characteristics Database/LOG +0 -3
  67. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Site Characteristics Database/MANIFEST-000001 +0 -0
  68. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Sync Data/LevelDB/LOCK +0 -0
  69. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Sync Data/LevelDB/LOG +0 -3
  70. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Sync Data/LevelDB/MANIFEST-000001 +0 -0
  71. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Top Sites +0 -0
  72. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Vpn Tokens +0 -0
  73. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Web Data +0 -0
  74. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/Web Data-journal +0 -0
  75. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/WebStorage/QuotaManager +0 -0
  76. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/WebStorage/QuotaManager-journal +0 -0
  77. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/heavy_ad_intervention_opt_out.db +0 -0
  78. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/shared_proto_db/LOCK +0 -0
  79. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/shared_proto_db/LOG +0 -3
  80. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/shared_proto_db/MANIFEST-000001 +0 -0
  81. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/LOCK +0 -0
  82. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/LOG +0 -3
  83. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/MANIFEST-000001 +0 -0
  84. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/DeferredBrowserMetrics/BrowserMetrics-69CAD063-BE24.pma +0 -0
  85. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Local State +0 -1
  86. package/launcher/bin/mailx-app.exe.WebView2/EBWebView/Variations +0 -1
  87. package/launcher/bin/mailx-app.old.exe +0 -0
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/bin/mailx.js CHANGED
@@ -58,9 +58,15 @@ async function main() {
58
58
  }
59
59
  } else {
60
60
  // Default: launch native WebView app with IPC
61
+ // Platform-specific binary naming (matches msger pattern)
62
+ let binaryName;
63
+ if (process.platform === "win32") binaryName = "mailx-app.exe";
64
+ else if (process.platform === "darwin") binaryName = process.arch === "arm64" ? "mailx-app-arm64" : "mailx-app";
65
+ else binaryName = process.arch === "arm64" ? "mailx-app-linux-aarch64" : "mailx-app-linux";
66
+
61
67
  const launcherPaths = [
62
- path.join(import.meta.dirname, "..", "launcher", "bin", "mailx-app.exe"),
63
- path.join(import.meta.dirname, "..", "launcher", "target", "debug", "mailx-app.exe"),
68
+ path.join(import.meta.dirname, "..", "launcher", "bin", binaryName),
69
+ path.join(import.meta.dirname, "..", "launcher", "target", "release", binaryName),
64
70
  ];
65
71
 
66
72
  let launcherPath = launcherPaths.find(p => fs.existsSync(p));
package/client/app.js CHANGED
@@ -11,6 +11,10 @@ const folderTree = document.getElementById("folder-tree");
11
11
  let currentFolderSpecialUse = "";
12
12
  initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
13
13
  currentFolderSpecialUse = specialUse;
14
+ currentAccountId = accountId;
15
+ currentFolderId = folderId;
16
+ if (searchInput)
17
+ searchInput.value = "";
14
18
  loadMessages(accountId, folderId, 1, specialUse);
15
19
  document.title = `mailx - ${folderName}`;
16
20
  }, () => {
@@ -207,32 +211,50 @@ document.getElementById("btn-forward")?.addEventListener("click", () => openComp
207
211
  // ── Search ──
208
212
  let searchTimeout;
209
213
  const searchInput = document.getElementById("search-input");
214
+ const searchScope = document.getElementById("search-scope");
215
+ function doSearch(immediate = false) {
216
+ const query = searchInput.value.trim();
217
+ if (query.length === 0) {
218
+ reloadCurrentFolder();
219
+ return;
220
+ }
221
+ if (query.length < 2 && !immediate)
222
+ return;
223
+ const scope = searchScope?.value || "all";
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
+ }
237
+ loadSearchResults(query, scope, currentAccountId, currentFolderId);
238
+ document.title = `mailx - Search: ${query}`;
239
+ }
240
+ // Track current folder for scoped search
241
+ let currentAccountId = "";
242
+ let currentFolderId = 0;
210
243
  searchInput?.addEventListener("input", () => {
211
244
  clearTimeout(searchTimeout);
212
- searchTimeout = setTimeout(() => {
213
- const query = searchInput.value.trim();
214
- if (query.length >= 2) {
215
- loadSearchResults(query);
216
- document.title = `mailx - Search: ${query}`;
217
- }
218
- else if (query.length === 0) {
219
- // Clear search — reload current folder
220
- reloadCurrentFolder();
221
- }
222
- }, 400);
245
+ searchTimeout = setTimeout(() => doSearch(false), 300);
223
246
  });
224
- // Enter triggers immediate search
225
247
  searchInput?.addEventListener("keydown", (e) => {
226
248
  if (e.key === "Enter") {
227
249
  clearTimeout(searchTimeout);
228
- const query = searchInput.value.trim();
229
- if (query) {
230
- loadSearchResults(query);
231
- document.title = `mailx - Search: ${query}`;
232
- }
250
+ doSearch(true);
233
251
  }
234
252
  if (e.key === "Escape") {
235
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"));
236
258
  reloadCurrentFolder();
237
259
  }
238
260
  });
@@ -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")
@@ -140,8 +116,7 @@ export async function loadUnifiedInbox() {
140
116
  }
141
117
  }
142
118
  /** Load search results */
143
- export async function loadSearchResults(query) {
144
- clearFilter();
119
+ export async function loadSearchResults(query, scope = "all", accountId = "", folderId = 0) {
145
120
  searchMode = true;
146
121
  unifiedMode = false;
147
122
  currentSearchQuery = query;
@@ -152,7 +127,35 @@ export async function loadSearchResults(query) {
152
127
  return;
153
128
  body.innerHTML = `<div class="ml-empty">Searching...</div>`;
154
129
  try {
155
- const result = await searchMessages(query, 1);
130
+ // Regex search: filter client-side
131
+ if (query.startsWith("/") && query.endsWith("/") && query.length > 2) {
132
+ const pattern = query.slice(1, -1);
133
+ let regex;
134
+ try {
135
+ regex = new RegExp(pattern, "i");
136
+ }
137
+ catch {
138
+ body.innerHTML = `<div class="ml-empty">Invalid regex</div>`;
139
+ return;
140
+ }
141
+ // Get all messages from current context and filter
142
+ const source = scope === "current" && accountId
143
+ ? await getMessages(accountId, folderId, 1, 10000)
144
+ : await searchMessages("*", 1, 10000, "all");
145
+ const matches = source.items.filter((m) => regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || ""));
146
+ totalMessages = matches.length;
147
+ if (matches.length === 0) {
148
+ body.innerHTML = `<div class="ml-empty">No regex matches</div>`;
149
+ return;
150
+ }
151
+ body.innerHTML = "";
152
+ appendMessages(body, "", matches);
153
+ const firstRow = body.querySelector(".ml-row");
154
+ if (firstRow)
155
+ firstRow.click();
156
+ return;
157
+ }
158
+ const result = await searchMessages(query, 1, 50, scope, accountId, folderId);
156
159
  totalMessages = result.total;
157
160
  if (result.items.length === 0) {
158
161
  body.innerHTML = `<div class="ml-empty">No results for "${query}"</div>`;
@@ -165,8 +168,7 @@ export async function loadSearchResults(query) {
165
168
  body.innerHTML = `<div class="ml-empty">Search error: ${e.message}</div>`;
166
169
  }
167
170
  }
168
- export async function loadMessages(accountId, folderId, page = 1, specialUse = "") {
169
- clearFilter();
171
+ export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
170
172
  searchMode = false;
171
173
  unifiedMode = false;
172
174
  showToInsteadOfFrom = ["sent", "drafts", "outbox"].includes(specialUse) ||
@@ -193,10 +195,12 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
193
195
  }
194
196
  body.innerHTML = "";
195
197
  appendMessages(body, accountId, result.items);
196
- // Auto-select first message
197
- const firstRow = body.querySelector(".ml-row");
198
- if (firstRow)
199
- 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
+ }
200
204
  }
201
205
  catch (e) {
202
206
  if (e.name === "AbortError")
@@ -42,13 +42,12 @@ export async function showMessage(accountId, uid, folderId, specialUse) {
42
42
  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
43
  // Unsubscribe button (upper right of header)
44
44
  const unsubBtn = document.getElementById("mv-unsubscribe");
45
- const headerUnsub = msg.listUnsubscribe || "";
46
- const headerUnsubUrl = headerUnsub.match(/<(https?:\/\/[^>]+)>/)?.[1]
47
- || headerUnsub.match(/<(mailto:[^>]+)>/)?.[1] || "";
45
+ // listUnsubscribe is now a clean URL (https:// or mailto:) from the server
46
+ const unsubUrl = msg.listUnsubscribe || "";
48
47
  if (unsubBtn) {
49
- if (headerUnsubUrl) {
48
+ if (unsubUrl) {
50
49
  unsubBtn.hidden = false;
51
- unsubBtn.href = headerUnsubUrl;
50
+ unsubBtn.href = unsubUrl;
52
51
  unsubBtn.target = "_blank";
53
52
  unsubBtn.rel = "noopener noreferrer";
54
53
  }
@@ -202,7 +201,8 @@ export async function showMessage(accountId, uid, folderId, specialUse) {
202
201
  else if (msg.bodyText) {
203
202
  const pre = document.createElement("pre");
204
203
  pre.style.cssText = "padding: 1rem; white-space: pre-wrap; word-break: break-word;";
205
- pre.textContent = msg.bodyText;
204
+ // Auto-linkify URLs in plain text
205
+ pre.innerHTML = linkifyText(msg.bodyText);
206
206
  bodyEl.appendChild(pre);
207
207
  }
208
208
  else {
@@ -221,7 +221,9 @@ export async function showMessage(accountId, uid, folderId, specialUse) {
221
221
  }
222
222
  }
223
223
  catch (e) {
224
- bodyEl.innerHTML = `<div class="mv-empty">Error: ${e.message}</div>`;
224
+ const msg = e.message || "Unknown error";
225
+ bodyEl.innerHTML = `<div class="mv-empty">Failed to load message: ${msg}<br><button onclick="location.reload()">Retry</button></div>`;
226
+ console.error("showMessage error:", e);
225
227
  }
226
228
  }
227
229
  function formatAddr(addr) {
@@ -229,6 +231,13 @@ function formatAddr(addr) {
229
231
  return `${addr.name} <${addr.address}>`;
230
232
  return addr.address;
231
233
  }
234
+ /** Convert plain text URLs into clickable links, escaping HTML */
235
+ function linkifyText(text) {
236
+ // Escape HTML first
237
+ const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
238
+ // Then linkify URLs
239
+ return escaped.replace(/(https?:\/\/[^\s<>"')\]]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
240
+ }
232
241
  function escapeText(s) {
233
242
  const div = document.createElement("div");
234
243
  div.textContent = s;
package/client/index.html CHANGED
@@ -13,24 +13,24 @@
13
13
  <body>
14
14
  <header class="toolbar">
15
15
  <div class="toolbar-left">
16
- <button class="tb-btn" id="btn-compose" title="Compose">
17
- <span class="tb-icon">&#x270F;</span> Compose
16
+ <button class="tb-btn" id="btn-compose" title="Compose (Ctrl+N)">
17
+ <span class="tb-icon">✏</span> Compose
18
18
  </button>
19
- <button class="tb-btn" id="btn-reply" title="Reply" disabled>
20
- <span class="tb-icon">&#x21A9;</span>
19
+ <button class="tb-btn" id="btn-reply" title="Reply (Ctrl+R)" disabled>
20
+ <span class="tb-icon">↩</span>
21
21
  </button>
22
- <button class="tb-btn" id="btn-reply-all" title="Reply All" disabled>
23
- <span class="tb-icon">&#x21A9;&#x21A9;</span>
22
+ <button class="tb-btn" id="btn-reply-all" title="Reply All (Ctrl+Shift+R)" disabled>
23
+ <span class="tb-icon">↩↩</span>
24
24
  </button>
25
25
  <button class="tb-btn" id="btn-forward" title="Forward" disabled>
26
- <span class="tb-icon">&#x2192;</span>
26
+ <span class="tb-icon">→</span>
27
27
  </button>
28
28
  <span class="tb-sep"></span>
29
- <button class="tb-btn" id="btn-delete" title="Delete" disabled>
30
- <span class="tb-icon">&#x1F5D1;</span>
29
+ <button class="tb-btn" id="btn-delete" title="Delete (Del)" disabled>
30
+ <span class="tb-icon">🗑</span>
31
31
  </button>
32
32
  <button class="tb-btn" id="btn-flag" title="Flag" disabled>
33
- <span class="tb-icon">&#x2691;</span>
33
+ <span class="tb-icon">⚑</span>
34
34
  </button>
35
35
  </div>
36
36
  <div class="toolbar-center">
@@ -45,14 +45,19 @@
45
45
  <span id="app-version" class="app-version"></span>
46
46
  </div>
47
47
  <div class="toolbar-right">
48
- <search>
49
- <input type="search" id="search-input" placeholder="Search..." autocomplete="off">
48
+ <search class="search-bar">
49
+ <select id="search-scope" title="Search scope">
50
+ <option value="all">All folders</option>
51
+ <option value="current">This folder</option>
52
+ <option value="server">IMAP server</option>
53
+ </select>
54
+ <input type="search" id="search-input" placeholder="Search... (/regex/)" autocomplete="off" title="Search messages. /pattern/ for regex. Qualifiers: from: to: subject:">
50
55
  </search>
51
56
  <button class="tb-btn" id="btn-sync" title="Sync all folders (F5)">
52
- <span class="tb-icon">&#x21BB;</span> Sync
57
+ <span class="tb-icon">↻</span> Sync
53
58
  </button>
54
59
  <button class="tb-btn" id="btn-restart" title="Restart server and reload page">
55
- <span class="tb-icon">&#x26A1;</span> Restart
60
+ <span class="tb-icon">⚡</span> Restart
56
61
  </button>
57
62
  </div>
58
63
  </header>
@@ -68,9 +73,6 @@
68
73
 
69
74
  <main class="main-area">
70
75
  <section class="message-list" id="message-list">
71
- <div class="ml-filter">
72
- <input type="text" id="ml-filter-input" placeholder="Filter..." autocomplete="off">
73
- </div>
74
76
  <div class="ml-header">
75
77
  <span class="ml-col ml-col-flag"></span>
76
78
  <span class="ml-col ml-col-from" data-sort="from">From</span>
@@ -92,7 +94,7 @@
92
94
  <div class="mv-to"></div>
93
95
  </div>
94
96
  <div class="mv-header-actions">
95
- <button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit &amp; Send</button>
97
+ <button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
96
98
  <a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
97
99
  <button class="mv-action" id="mv-view-source" title="View source (.eml)" hidden>Source</button>
98
100
  </div>
@@ -52,10 +52,19 @@ export function getUnifiedInbox(page = 1, pageSize = 50) {
52
52
  const signal = newMessageListSignal();
53
53
  return api(`/messages/unified/inbox?page=${page}&pageSize=${pageSize}`, { signal });
54
54
  }
55
- export function searchMessages(query, page = 1, pageSize = 50) {
55
+ export function searchMessages(query, page = 1, pageSize = 50, scope = "all", accountId = "", folderId = 0) {
56
56
  if (hasIPC)
57
57
  return mailxapi.searchMessages(query, page, pageSize);
58
- return api(`/search?q=${encodeURIComponent(query)}&page=${page}&pageSize=${pageSize}`);
58
+ const params = new URLSearchParams({ q: query, page: String(page), pageSize: String(pageSize), scope });
59
+ if (scope === "current" && accountId) {
60
+ params.set("accountId", accountId);
61
+ params.set("folderId", String(folderId));
62
+ }
63
+ if (scope === "server" && accountId) {
64
+ params.set("accountId", accountId);
65
+ params.set("folderId", String(folderId));
66
+ }
67
+ return api(`/search?${params}`);
59
68
  }
60
69
  export function getMessage(accountId, uid, allowRemote = false, folderId) {
61
70
  if (hasIPC)
@@ -68,14 +68,31 @@
68
68
  .tb-menu-item input[type="checkbox"] { accent-color: var(--color-accent); }
69
69
  .tb-sep { width: 1px; height: 1.2rem; background: var(--color-border); margin: 0 var(--gap-xs); }
70
70
 
71
+ .search-bar {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 0;
75
+ }
76
+
77
+ #search-scope {
78
+ padding: var(--gap-xs) var(--gap-xs);
79
+ border: 1px solid var(--color-border);
80
+ border-radius: var(--radius-md) 0 0 var(--radius-md);
81
+ background: var(--color-bg-surface);
82
+ color: var(--color-text);
83
+ font-size: var(--font-size-sm);
84
+ border-right: none;
85
+ cursor: pointer;
86
+ }
87
+
71
88
  #search-input {
72
89
  padding: var(--gap-xs) var(--gap-sm);
73
90
  border: 1px solid var(--color-border);
74
- border-radius: var(--radius-md);
91
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
75
92
  background: var(--color-bg-surface);
76
93
  color: var(--color-text);
77
94
  font-size: var(--font-size-sm);
78
- width: 200px;
95
+ width: 350px;
79
96
 
80
97
  &::placeholder { color: var(--color-text-muted); }
81
98
  &:focus { outline: 1px solid var(--color-accent); border-color: var(--color-accent); }
@@ -201,7 +218,7 @@
201
218
  .message-list {
202
219
  display: grid;
203
220
  grid-template-columns: 1.2em minmax(120px, 200px) auto 1fr;
204
- grid-template-rows: auto auto 1fr;
221
+ grid-template-rows: auto 1fr;
205
222
  column-gap: var(--gap-sm);
206
223
  overflow: hidden;
207
224
  border-right: 1px solid var(--color-border);
@@ -227,28 +244,6 @@
227
244
  font-size: var(--font-size-sm);
228
245
  }
229
246
 
230
- .ml-filter {
231
- grid-column: 1 / -1;
232
- padding: 2px var(--gap-xs);
233
- border-bottom: 1px solid var(--color-border);
234
- background: var(--color-bg-surface);
235
- }
236
-
237
- .ml-filter input {
238
- width: 100%;
239
- padding: 2px var(--gap-sm);
240
- border: 1px solid var(--color-border);
241
- border-radius: var(--radius-sm);
242
- background: var(--color-bg);
243
- color: var(--color-text);
244
- font-size: var(--font-size-sm);
245
- outline: none;
246
- }
247
-
248
- .ml-filter input:focus {
249
- border-color: var(--color-accent);
250
- }
251
-
252
247
  .ml-row.filter-hidden { display: none; }
253
248
 
254
249
  .ml-header {
Binary file
Binary file
@@ -0,0 +1,11 @@
1
+ {
2
+ "platforms": {
3
+ "windows": true,
4
+ "wsl": true,
5
+ "arm64": false
6
+ },
7
+ "options": {
8
+ "release": true,
9
+ "verbose": true
10
+ }
11
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mailx postinstall — delegates to @bobfrankston/rust-builder
4
+ */
5
+ import { runPostinstall } from "@bobfrankston/rust-builder/postinstall";
6
+ import path from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+
11
+ runPostinstall({
12
+ binaryName: "mailx-app",
13
+ binDir: path.join(__dirname, "..", "bin"),
14
+ binaries: {
15
+ win32: "mailx-app.exe",
16
+ darwin: "mailx-app",
17
+ darwinArm64: "mailx-app-arm64",
18
+ linux: "mailx-app-linux",
19
+ linuxArm64: "mailx-app-linux-aarch64",
20
+ },
21
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -17,13 +17,13 @@
17
17
  "start": "node --watch packages/mailx-server/index.js",
18
18
  "start:prod": "node packages/mailx-server/index.js",
19
19
  "release": "npmglobalize",
20
- "postinstall": "node bin/postinstall.js"
20
+ "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
23
  "@bobfrankston/iflow": "^1.0.2",
24
24
  "@bobfrankston/miscinfo": "^1.0.5",
25
25
  "@bobfrankston/oauthsupport": "^1.0.10",
26
- "@bobfrankston/certsupport": "^1.0.35",
26
+ "@bobfrankston/rust-builder": "^0.1.1",
27
27
  "mailparser": "^3.7.2",
28
28
  "quill": "^2.0.3",
29
29
  "express": "^4.21.0",
@@ -52,7 +52,7 @@
52
52
  "@bobfrankston/iflow": "file:../MailApps/iflow",
53
53
  "@bobfrankston/miscinfo": "file:../../projects/npm/miscinfo",
54
54
  "@bobfrankston/oauthsupport": "file:../../projects/oauth/oauthsupport",
55
- "@bobfrankston/certsupport": "file:../../projects/nodejs/certsupport",
55
+ "@bobfrankston/rust-builder": "file:../../utils/rust-builder",
56
56
  "mailparser": "^3.7.2",
57
57
  "quill": "^2.0.3",
58
58
  "express": "^4.21.0",