@bobfrankston/mailx 1.0.14 → 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.
- package/README.md +11 -7
- package/client/app.js +26 -7
- package/client/components/message-list.js +16 -40
- package/client/components/message-viewer.js +16 -7
- package/client/index.html +0 -3
- package/client/styles/components.css +2 -24
- package/launcher/bin/mailx-app.exe +0 -0
- package/launcher/builder/postinstall.js +17 -77
- package/package.json +3 -1
- package/packages/mailx-api/index.js +38 -7
- package/packages/mailx-core/index.js +17 -6
- package/packages/mailx-imap/index.d.ts +2 -0
- package/packages/mailx-imap/index.js +24 -4
package/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
# mailx
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
216
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
});
|
|
@@ -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
|
-
|
|
133
|
-
|
|
134
|
-
firstRow
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
firstRow
|
|
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")
|
|
@@ -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
|
-
|
|
46
|
-
const
|
|
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 (
|
|
48
|
+
if (unsubUrl) {
|
|
50
49
|
unsubBtn.hidden = false;
|
|
51
|
-
unsubBtn.href =
|
|
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
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
@@ -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>
|
|
@@ -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:
|
|
95
|
+
width: 350px;
|
|
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
|
|
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,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
* and check for required system dependencies (webkit2gtk on Linux).
|
|
3
|
+
* mailx postinstall — delegates to @bobfrankston/rust-builder
|
|
5
4
|
*/
|
|
6
|
-
|
|
7
|
-
import fs from "fs";
|
|
5
|
+
import { runPostinstall } from "@bobfrankston/rust-builder/postinstall";
|
|
8
6
|
import path from "path";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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");
|
|
30
|
-
}
|
|
31
|
-
}
|
|
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();
|
|
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.
|
|
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",
|
|
@@ -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",
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"@bobfrankston/iflow": "file:../MailApps/iflow",
|
|
52
53
|
"@bobfrankston/miscinfo": "file:../../projects/npm/miscinfo",
|
|
53
54
|
"@bobfrankston/oauthsupport": "file:../../projects/oauth/oauthsupport",
|
|
55
|
+
"@bobfrankston/rust-builder": "file:../../utils/rust-builder",
|
|
54
56
|
"mailparser": "^3.7.2",
|
|
55
57
|
"quill": "^2.0.3",
|
|
56
58
|
"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
|
-
|
|
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
|
-
|
|
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" &&
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|