@bobfrankston/mailx 1.0.35 → 1.0.36
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 -0
- package/client/app.js +91 -4
- package/client/components/context-menu.js +51 -0
- package/client/components/folder-tree.js +87 -0
- package/client/components/message-list.js +23 -1
- package/client/components/message-viewer.js +35 -4
- package/client/index.html +10 -8
- package/client/styles/components.css +52 -4
- package/package.json +2 -2
- package/packages/mailx-api/index.js +178 -1
- package/packages/mailx-core/index.js +31 -1
- package/packages/mailx-imap/index.d.ts +2 -0
- package/packages/mailx-imap/index.js +4 -0
- package/packages/mailx-settings/index.js +18 -6
- package/packages/mailx-store/db.d.ts +2 -0
- package/packages/mailx-store/db.js +8 -0
- package/packages/mailx-types/index.d.ts +2 -0
package/README.md
CHANGED
|
@@ -141,9 +141,20 @@ Gmail requires OAuth2 instead of a password:
|
|
|
141
141
|
- **Delete** -- Del key or trash button, moves to Trash. Ctrl+Z to undo (30s).
|
|
142
142
|
- **Flag/unflag** -- click the star
|
|
143
143
|
- **Drag and drop** -- drag messages to folders to move them. Shift/Ctrl+click for multi-select.
|
|
144
|
+
- **Attachments** -- click the chip to open PDFs, images, etc. in the browser
|
|
144
145
|
- **Remote content** -- blocked by default. "Load once" or "Always" buttons on the banner.
|
|
145
146
|
- **Unsubscribe** -- button appears in header when List-Unsubscribe header is present
|
|
146
147
|
- **View Source** -- Source button copies .eml file path to clipboard
|
|
148
|
+
- **Link preview** -- hover over links in email body to see URL in status bar
|
|
149
|
+
|
|
150
|
+
### Managing Folders
|
|
151
|
+
- **Right-click any folder** for context menu:
|
|
152
|
+
- Mark all read
|
|
153
|
+
- New subfolder
|
|
154
|
+
- Rename folder
|
|
155
|
+
- Delete folder
|
|
156
|
+
- Empty folder (Trash/Junk only -- permanently deletes all messages)
|
|
157
|
+
- Special folders (Inbox, Sent, Trash, etc.) cannot be renamed or deleted
|
|
147
158
|
|
|
148
159
|
### Keyboard Shortcuts
|
|
149
160
|
| Key | Action |
|
package/client/app.js
CHANGED
|
@@ -5,7 +5,91 @@
|
|
|
5
5
|
import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
|
|
8
|
-
import { connectWebSocket, onWsEvent, triggerSync, getAccounts } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders } from "./lib/api-client.js";
|
|
9
|
+
// ── New message badge (favicon + title) ──
|
|
10
|
+
let baseTitle = "mailx";
|
|
11
|
+
let lastSeenCount = 0;
|
|
12
|
+
let badgeCount = 0;
|
|
13
|
+
function updateBadge(count) {
|
|
14
|
+
badgeCount = count;
|
|
15
|
+
// Update title
|
|
16
|
+
document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
|
|
17
|
+
// Update favicon with badge
|
|
18
|
+
const canvas = document.createElement("canvas");
|
|
19
|
+
canvas.width = 32;
|
|
20
|
+
canvas.height = 32;
|
|
21
|
+
const ctx = canvas.getContext("2d");
|
|
22
|
+
// Draw base icon (envelope)
|
|
23
|
+
ctx.fillStyle = "#4a7ccc";
|
|
24
|
+
ctx.fillRect(2, 8, 28, 20);
|
|
25
|
+
ctx.fillStyle = "#6a9cec";
|
|
26
|
+
ctx.beginPath();
|
|
27
|
+
ctx.moveTo(2, 8);
|
|
28
|
+
ctx.lineTo(16, 20);
|
|
29
|
+
ctx.lineTo(30, 8);
|
|
30
|
+
ctx.fill();
|
|
31
|
+
if (count > 0) {
|
|
32
|
+
// Red badge circle
|
|
33
|
+
ctx.fillStyle = "#e33";
|
|
34
|
+
ctx.beginPath();
|
|
35
|
+
ctx.arc(24, 8, 8, 0, Math.PI * 2);
|
|
36
|
+
ctx.fill();
|
|
37
|
+
// Badge number
|
|
38
|
+
ctx.fillStyle = "#fff";
|
|
39
|
+
ctx.font = "bold 11px sans-serif";
|
|
40
|
+
ctx.textAlign = "center";
|
|
41
|
+
ctx.textBaseline = "middle";
|
|
42
|
+
ctx.fillText(count > 99 ? "99+" : String(count), 24, 8);
|
|
43
|
+
}
|
|
44
|
+
// Set as favicon
|
|
45
|
+
let link = document.querySelector("link[rel='icon']");
|
|
46
|
+
if (!link) {
|
|
47
|
+
link = document.createElement("link");
|
|
48
|
+
link.rel = "icon";
|
|
49
|
+
document.head.appendChild(link);
|
|
50
|
+
}
|
|
51
|
+
link.href = canvas.toDataURL();
|
|
52
|
+
}
|
|
53
|
+
async function updateNewMessageCount() {
|
|
54
|
+
try {
|
|
55
|
+
const accounts = await getAccounts();
|
|
56
|
+
let totalUnread = 0;
|
|
57
|
+
for (const acct of accounts) {
|
|
58
|
+
const folders = await getFolders(acct.id);
|
|
59
|
+
const inbox = folders.find((f) => f.specialUse === "inbox");
|
|
60
|
+
if (inbox)
|
|
61
|
+
totalUnread += inbox.unreadCount || 0;
|
|
62
|
+
}
|
|
63
|
+
// First load: set baseline
|
|
64
|
+
if (lastSeenCount === 0) {
|
|
65
|
+
lastSeenCount = totalUnread;
|
|
66
|
+
updateBadge(0);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// New messages = increase since last seen
|
|
70
|
+
const newCount = Math.max(0, totalUnread - lastSeenCount);
|
|
71
|
+
updateBadge(newCount);
|
|
72
|
+
}
|
|
73
|
+
catch { /* offline */ }
|
|
74
|
+
}
|
|
75
|
+
/** Call when user actively views messages — resets the badge */
|
|
76
|
+
function markAsSeen() {
|
|
77
|
+
getAccounts().then(async (accounts) => {
|
|
78
|
+
let total = 0;
|
|
79
|
+
for (const acct of accounts) {
|
|
80
|
+
const folders = await getFolders(acct.id);
|
|
81
|
+
const inbox = folders.find((f) => f.specialUse === "inbox");
|
|
82
|
+
if (inbox)
|
|
83
|
+
total += inbox.unreadCount || 0;
|
|
84
|
+
}
|
|
85
|
+
lastSeenCount = total;
|
|
86
|
+
updateBadge(0);
|
|
87
|
+
}).catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
function setTitle(title) {
|
|
90
|
+
baseTitle = title;
|
|
91
|
+
document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;
|
|
92
|
+
}
|
|
9
93
|
// ── Wire up components ──
|
|
10
94
|
const folderTree = document.getElementById("folder-tree");
|
|
11
95
|
let currentFolderSpecialUse = "";
|
|
@@ -15,13 +99,14 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
15
99
|
currentFolderId = folderId;
|
|
16
100
|
if (searchInput)
|
|
17
101
|
searchInput.value = "";
|
|
102
|
+
markAsSeen();
|
|
18
103
|
loadMessages(accountId, folderId, 1, specialUse);
|
|
19
|
-
|
|
104
|
+
setTitle(`mailx - ${folderName}`);
|
|
20
105
|
}, () => {
|
|
21
106
|
// Unified inbox handler
|
|
22
107
|
currentFolderSpecialUse = "inbox";
|
|
23
108
|
loadUnifiedInbox();
|
|
24
|
-
|
|
109
|
+
setTitle("mailx - All Inboxes");
|
|
25
110
|
});
|
|
26
111
|
initMessageList((accountId, uid, folderId) => {
|
|
27
112
|
showMessage(accountId, uid, folderId, currentFolderSpecialUse);
|
|
@@ -235,7 +320,7 @@ function doSearch(immediate = false) {
|
|
|
235
320
|
return;
|
|
236
321
|
}
|
|
237
322
|
loadSearchResults(query, scope, currentAccountId, currentFolderId);
|
|
238
|
-
|
|
323
|
+
setTitle(`mailx - Search: ${query}`);
|
|
239
324
|
}
|
|
240
325
|
// Track current folder for scoped search
|
|
241
326
|
let currentAccountId = "";
|
|
@@ -374,6 +459,7 @@ onWsEvent((event) => {
|
|
|
374
459
|
break;
|
|
375
460
|
case "folderCountsChanged": {
|
|
376
461
|
refreshFolderTree();
|
|
462
|
+
updateNewMessageCount();
|
|
377
463
|
// Only reload message list if user isn't reading a message
|
|
378
464
|
if (!getCurrentMessage())
|
|
379
465
|
reloadCurrentFolder();
|
|
@@ -552,6 +638,7 @@ setInterval(async () => {
|
|
|
552
638
|
}
|
|
553
639
|
}, 5000);
|
|
554
640
|
console.log("mailx client initialized, location:", location.href);
|
|
641
|
+
updateNewMessageCount();
|
|
555
642
|
// Diagnostic: test API connectivity (helps debug WebView2 blank screen)
|
|
556
643
|
fetch("/api/version").then(r => r.json()).then(d => {
|
|
557
644
|
console.log("API reachable:", d);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple context menu component.
|
|
3
|
+
* Shows a menu at a given position with clickable items.
|
|
4
|
+
*/
|
|
5
|
+
let activeMenu = null;
|
|
6
|
+
/** Close any open context menu */
|
|
7
|
+
export function closeContextMenu() {
|
|
8
|
+
if (activeMenu) {
|
|
9
|
+
activeMenu.remove();
|
|
10
|
+
activeMenu = null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Show a context menu at the given position */
|
|
14
|
+
export function showContextMenu(x, y, items) {
|
|
15
|
+
closeContextMenu();
|
|
16
|
+
const menu = document.createElement("div");
|
|
17
|
+
menu.className = "ctx-menu";
|
|
18
|
+
for (const item of items) {
|
|
19
|
+
if (item.separator) {
|
|
20
|
+
const sep = document.createElement("div");
|
|
21
|
+
sep.className = "ctx-sep";
|
|
22
|
+
menu.appendChild(sep);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const el = document.createElement("div");
|
|
26
|
+
el.className = "ctx-item" + (item.disabled ? " ctx-disabled" : "");
|
|
27
|
+
el.textContent = item.label;
|
|
28
|
+
if (!item.disabled) {
|
|
29
|
+
el.addEventListener("click", () => {
|
|
30
|
+
closeContextMenu();
|
|
31
|
+
item.action();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
menu.appendChild(el);
|
|
35
|
+
}
|
|
36
|
+
menu.style.left = `${x}px`;
|
|
37
|
+
menu.style.top = `${y}px`;
|
|
38
|
+
document.body.appendChild(menu);
|
|
39
|
+
// Adjust if menu goes off-screen
|
|
40
|
+
const rect = menu.getBoundingClientRect();
|
|
41
|
+
if (rect.right > window.innerWidth)
|
|
42
|
+
menu.style.left = `${x - rect.width}px`;
|
|
43
|
+
if (rect.bottom > window.innerHeight)
|
|
44
|
+
menu.style.top = `${y - rect.height}px`;
|
|
45
|
+
activeMenu = menu;
|
|
46
|
+
}
|
|
47
|
+
// Close on click outside or Escape
|
|
48
|
+
document.addEventListener("click", closeContextMenu);
|
|
49
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape")
|
|
50
|
+
closeContextMenu(); });
|
|
51
|
+
//# sourceMappingURL=context-menu.js.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* expand/collapse, and optional unified inbox.
|
|
4
4
|
*/
|
|
5
5
|
import { getAccounts, getFolders } from "../lib/api-client.js";
|
|
6
|
+
import { showContextMenu } from "./context-menu.js";
|
|
6
7
|
let onFolderSelect;
|
|
7
8
|
let onUnifiedInbox = null;
|
|
8
9
|
let selectedElement;
|
|
@@ -156,6 +157,92 @@ function renderNode(node, container, depth) {
|
|
|
156
157
|
selectedFolderId = node.id;
|
|
157
158
|
onFolderSelect(node.accountId, node.id, node.name, node.specialUse || node.path.toLowerCase());
|
|
158
159
|
});
|
|
160
|
+
// ── Right-click context menu ──
|
|
161
|
+
folderEl.addEventListener("contextmenu", (e) => {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
e.stopPropagation();
|
|
164
|
+
const isTrash = node.specialUse === "trash" || node.path.toLowerCase().includes("trash");
|
|
165
|
+
const isJunk = node.specialUse === "junk" || node.path.toLowerCase().includes("spam") || node.path.toLowerCase().includes("junk");
|
|
166
|
+
const items = [
|
|
167
|
+
{ label: "Mark all read", action: async () => {
|
|
168
|
+
try {
|
|
169
|
+
await fetch(`/api/folder/${node.accountId}/${node.id}/mark-read`, { method: "POST" });
|
|
170
|
+
const treeContainer = document.getElementById("folder-tree");
|
|
171
|
+
if (treeContainer)
|
|
172
|
+
loadFolderTree(treeContainer);
|
|
173
|
+
}
|
|
174
|
+
catch { /* ignore */ }
|
|
175
|
+
} },
|
|
176
|
+
{ label: "", action: () => { }, separator: true },
|
|
177
|
+
{ label: "New subfolder...", action: async () => {
|
|
178
|
+
const name = prompt("New folder name:");
|
|
179
|
+
if (!name)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
await fetch(`/api/folder/${node.accountId}`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "Content-Type": "application/json" },
|
|
185
|
+
body: JSON.stringify({ parentPath: node.path, name }),
|
|
186
|
+
});
|
|
187
|
+
const treeContainer = document.getElementById("folder-tree");
|
|
188
|
+
if (treeContainer)
|
|
189
|
+
loadFolderTree(treeContainer);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
alert(`Failed: ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
} },
|
|
195
|
+
{ label: "Rename...", action: async () => {
|
|
196
|
+
const newName = prompt("Rename folder:", node.name);
|
|
197
|
+
if (!newName || newName === node.name)
|
|
198
|
+
return;
|
|
199
|
+
try {
|
|
200
|
+
await fetch(`/api/folder/${node.accountId}/${node.id}/rename`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify({ newName }),
|
|
204
|
+
});
|
|
205
|
+
const treeContainer = document.getElementById("folder-tree");
|
|
206
|
+
if (treeContainer)
|
|
207
|
+
loadFolderTree(treeContainer);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
alert(`Failed: ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
}, disabled: !!node.specialUse },
|
|
213
|
+
{ label: "Delete folder", action: async () => {
|
|
214
|
+
if (!confirm(`Delete folder "${node.name}"? Messages will be moved to Trash.`))
|
|
215
|
+
return;
|
|
216
|
+
try {
|
|
217
|
+
await fetch(`/api/folder/${node.accountId}/${node.id}`, { method: "DELETE" });
|
|
218
|
+
const treeContainer = document.getElementById("folder-tree");
|
|
219
|
+
if (treeContainer)
|
|
220
|
+
loadFolderTree(treeContainer);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
alert(`Failed: ${err.message}`);
|
|
224
|
+
}
|
|
225
|
+
}, disabled: !!node.specialUse },
|
|
226
|
+
];
|
|
227
|
+
if (isTrash || isJunk) {
|
|
228
|
+
items.push({ label: "", action: () => { }, separator: true });
|
|
229
|
+
items.push({ label: `Empty ${node.name}`, action: async () => {
|
|
230
|
+
if (!confirm(`Permanently delete all messages in "${node.name}"?`))
|
|
231
|
+
return;
|
|
232
|
+
try {
|
|
233
|
+
await fetch(`/api/folder/${node.accountId}/${node.id}/empty`, { method: "POST" });
|
|
234
|
+
const treeContainer = document.getElementById("folder-tree");
|
|
235
|
+
if (treeContainer)
|
|
236
|
+
loadFolderTree(treeContainer);
|
|
237
|
+
document.dispatchEvent(new CustomEvent("mailx-message-moved"));
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
alert(`Failed: ${err.message}`);
|
|
241
|
+
}
|
|
242
|
+
} });
|
|
243
|
+
}
|
|
244
|
+
showContextMenu(e.clientX, e.clientY, items);
|
|
245
|
+
});
|
|
159
246
|
// ── Drop target for message drag-and-drop ──
|
|
160
247
|
if (node.id !== -1) {
|
|
161
248
|
folderEl.addEventListener("dragover", (e) => {
|
|
@@ -92,6 +92,8 @@ export async function loadUnifiedInbox(autoSelect = true) {
|
|
|
92
92
|
const body = document.getElementById("ml-body");
|
|
93
93
|
if (!body)
|
|
94
94
|
return;
|
|
95
|
+
const savedScroll = !autoSelect ? body.scrollTop : 0;
|
|
96
|
+
const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
|
|
95
97
|
body.innerHTML = `<div class="ml-empty">Loading...</div>`;
|
|
96
98
|
try {
|
|
97
99
|
const result = await getUnifiedInbox(1);
|
|
@@ -108,6 +110,14 @@ export async function loadUnifiedInbox(autoSelect = true) {
|
|
|
108
110
|
if (firstRow)
|
|
109
111
|
firstRow.click();
|
|
110
112
|
}
|
|
113
|
+
else {
|
|
114
|
+
body.scrollTop = savedScroll;
|
|
115
|
+
if (savedUid) {
|
|
116
|
+
const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
|
|
117
|
+
if (row)
|
|
118
|
+
row.classList.add("selected");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
111
121
|
}
|
|
112
122
|
catch (e) {
|
|
113
123
|
if (e.name === "AbortError")
|
|
@@ -184,6 +194,9 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
|
|
|
184
194
|
const fromHeader = document.querySelector(".ml-col-from");
|
|
185
195
|
if (fromHeader)
|
|
186
196
|
fromHeader.textContent = showToInsteadOfFrom ? "To" : "From";
|
|
197
|
+
// Save scroll position and selected UID for non-autoSelect reloads
|
|
198
|
+
const savedScroll = !autoSelect ? body.scrollTop : 0;
|
|
199
|
+
const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
|
|
187
200
|
body.innerHTML = `<div class="ml-empty">Loading...</div>`;
|
|
188
201
|
try {
|
|
189
202
|
const result = await getMessages(accountId, folderId, 1);
|
|
@@ -195,12 +208,21 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
|
|
|
195
208
|
}
|
|
196
209
|
body.innerHTML = "";
|
|
197
210
|
appendMessages(body, accountId, result.items);
|
|
198
|
-
// Auto-select first message only on explicit folder navigation, not sync reload
|
|
199
211
|
if (autoSelect) {
|
|
212
|
+
// Explicit folder navigation — select first message
|
|
200
213
|
const firstRow = body.querySelector(".ml-row");
|
|
201
214
|
if (firstRow)
|
|
202
215
|
firstRow.click();
|
|
203
216
|
}
|
|
217
|
+
else {
|
|
218
|
+
// Sync reload — restore scroll position and selection
|
|
219
|
+
body.scrollTop = savedScroll;
|
|
220
|
+
if (savedUid) {
|
|
221
|
+
const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
|
|
222
|
+
if (row)
|
|
223
|
+
row.classList.add("selected");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
204
226
|
}
|
|
205
227
|
catch (e) {
|
|
206
228
|
if (e.name === "AbortError")
|
|
@@ -109,6 +109,31 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
109
109
|
editBtn.hidden = true;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
// Details toggle — show extra headers (Delivered-To, Return-Path, Message-ID, etc.)
|
|
113
|
+
const detailsEl = document.getElementById("mv-details");
|
|
114
|
+
const detailsBtn = document.getElementById("mv-toggle-details");
|
|
115
|
+
if (detailsEl && detailsBtn) {
|
|
116
|
+
const lines = [];
|
|
117
|
+
if (msg.deliveredTo)
|
|
118
|
+
lines.push(`<span class="mv-details-label">Delivered-To:</span> ${escapeText(msg.deliveredTo)}`);
|
|
119
|
+
if (msg.returnPath)
|
|
120
|
+
lines.push(`<span class="mv-details-label">Return-Path:</span> ${escapeText(msg.returnPath)}`);
|
|
121
|
+
if (msg.messageId)
|
|
122
|
+
lines.push(`<span class="mv-details-label">Message-ID:</span> ${escapeText(msg.messageId)}`);
|
|
123
|
+
if (msg.listUnsubscribe)
|
|
124
|
+
lines.push(`<span class="mv-details-label">Unsubscribe:</span> ${escapeText(msg.listUnsubscribe)}`);
|
|
125
|
+
if (msg.emlPath)
|
|
126
|
+
lines.push(`<span class="mv-details-label">EML file:</span> ${escapeText(msg.emlPath)}`);
|
|
127
|
+
lines.push(`<span class="mv-details-label">Account:</span> ${escapeText(accountId)}`);
|
|
128
|
+
lines.push(`<span class="mv-details-label">UID:</span> ${msg.uid} (folder ${msg.folderId})`);
|
|
129
|
+
detailsEl.innerHTML = lines.join("<br>");
|
|
130
|
+
detailsEl.hidden = true;
|
|
131
|
+
detailsBtn.textContent = "Details";
|
|
132
|
+
detailsBtn.onclick = () => {
|
|
133
|
+
detailsEl.hidden = !detailsEl.hidden;
|
|
134
|
+
detailsBtn.textContent = detailsEl.hidden ? "Details" : "\u2713 Details";
|
|
135
|
+
};
|
|
136
|
+
}
|
|
112
137
|
// Remote content banner (collapsible dropdown with sender/recipient details)
|
|
113
138
|
bodyEl.innerHTML = "";
|
|
114
139
|
if (msg.hasRemoteContent) {
|
|
@@ -215,10 +240,14 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
215
240
|
if (msg.attachments?.length) {
|
|
216
241
|
attEl.hidden = false;
|
|
217
242
|
attEl.innerHTML = "";
|
|
218
|
-
for (
|
|
219
|
-
const
|
|
243
|
+
for (let i = 0; i < msg.attachments.length; i++) {
|
|
244
|
+
const att = msg.attachments[i];
|
|
245
|
+
const chip = document.createElement("a");
|
|
220
246
|
chip.className = "mv-att-chip";
|
|
221
247
|
chip.textContent = `\uD83D\uDCCE ${att.filename} (${formatSize(att.size)})`;
|
|
248
|
+
chip.href = `/api/message/${accountId}/${uid}/attachment/${i}?folderId=${msg.folderId}`;
|
|
249
|
+
chip.target = "_blank";
|
|
250
|
+
chip.title = `${att.filename} (${att.mimeType})`;
|
|
222
251
|
attEl.appendChild(chip);
|
|
223
252
|
}
|
|
224
253
|
}
|
|
@@ -226,14 +255,16 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
226
255
|
catch (e) {
|
|
227
256
|
const err = e.message || "Unknown error";
|
|
228
257
|
console.error("showMessage error:", e);
|
|
229
|
-
|
|
258
|
+
// Don't retry on "not found" or known errors — only on connection issues
|
|
259
|
+
const isNotFound = err.includes("not found") || err.includes("Not Found") || err.includes("404");
|
|
260
|
+
if (!isNotFound && retryCount < 3) {
|
|
230
261
|
retryCount++;
|
|
231
262
|
bodyEl.innerHTML = `<div class="mv-empty">Loading failed: ${err} — retrying (${retryCount}/3)...</div>`;
|
|
232
263
|
setTimeout(() => { if (gen === showMessageGeneration)
|
|
233
264
|
showMessage(accountId, uid, folderId, specialUse, true); }, 3000);
|
|
234
265
|
}
|
|
235
266
|
else {
|
|
236
|
-
bodyEl.innerHTML = `<div class="mv-empty"
|
|
267
|
+
bodyEl.innerHTML = `<div class="mv-empty">${isNotFound ? "Message was moved or deleted" : `Failed to load: ${err}`}</div>`;
|
|
237
268
|
}
|
|
238
269
|
}
|
|
239
270
|
}
|
package/client/index.html
CHANGED
|
@@ -45,14 +45,6 @@
|
|
|
45
45
|
<span id="app-version" class="app-version"></span>
|
|
46
46
|
</div>
|
|
47
47
|
<div class="toolbar-right">
|
|
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:">
|
|
55
|
-
</search>
|
|
56
48
|
<button class="tb-btn" id="btn-sync" title="Sync all folders (F5)">
|
|
57
49
|
<span class="tb-icon">↻</span> Sync
|
|
58
50
|
</button>
|
|
@@ -73,6 +65,14 @@
|
|
|
73
65
|
|
|
74
66
|
<main class="main-area">
|
|
75
67
|
<section class="message-list" id="message-list">
|
|
68
|
+
<search class="search-bar ml-search">
|
|
69
|
+
<select id="search-scope" title="Search scope">
|
|
70
|
+
<option value="all">All folders</option>
|
|
71
|
+
<option value="current">This folder</option>
|
|
72
|
+
<option value="server">IMAP server</option>
|
|
73
|
+
</select>
|
|
74
|
+
<input type="search" id="search-input" placeholder="Search... (/regex/)" autocomplete="off" title="Search messages. /pattern/ for regex. Qualifiers: from: to: subject:">
|
|
75
|
+
</search>
|
|
76
76
|
<div class="ml-header">
|
|
77
77
|
<span class="ml-col ml-col-flag"></span>
|
|
78
78
|
<span class="ml-col ml-col-from" data-sort="from">From</span>
|
|
@@ -97,10 +97,12 @@
|
|
|
97
97
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
|
98
98
|
<a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
|
|
99
99
|
<button class="mv-action" id="mv-view-source" title="View source (.eml)" hidden>Source</button>
|
|
100
|
+
<button class="mv-action" id="mv-toggle-details" title="Show/hide extra headers">Details</button>
|
|
100
101
|
</div>
|
|
101
102
|
</div>
|
|
102
103
|
<div class="mv-subject"></div>
|
|
103
104
|
<div class="mv-date"></div>
|
|
105
|
+
<div class="mv-details" id="mv-details" hidden></div>
|
|
104
106
|
</div>
|
|
105
107
|
<div class="mv-body" id="mv-body">
|
|
106
108
|
<div class="mv-empty">Select a message to read</div>
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
/* mailx component styles */
|
|
2
2
|
|
|
3
|
+
/* ── Context Menu ── */
|
|
4
|
+
|
|
5
|
+
.ctx-menu {
|
|
6
|
+
position: fixed;
|
|
7
|
+
z-index: 1000;
|
|
8
|
+
background: var(--color-bg-surface);
|
|
9
|
+
border: 1px solid var(--color-border);
|
|
10
|
+
border-radius: var(--radius-md);
|
|
11
|
+
padding: var(--gap-xs) 0;
|
|
12
|
+
min-width: 180px;
|
|
13
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ctx-item {
|
|
17
|
+
padding: var(--gap-xs) var(--gap-md);
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
font-size: var(--font-size-sm);
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.ctx-item:hover { background: var(--color-bg-hover); }
|
|
24
|
+
.ctx-disabled { opacity: 0.4; cursor: default; }
|
|
25
|
+
.ctx-disabled:hover { background: transparent; }
|
|
26
|
+
.ctx-sep { height: 1px; background: var(--color-border); margin: var(--gap-xs) 0; }
|
|
27
|
+
|
|
3
28
|
/* ── Toolbar ── */
|
|
4
29
|
|
|
5
30
|
.toolbar {
|
|
@@ -85,14 +110,21 @@
|
|
|
85
110
|
cursor: pointer;
|
|
86
111
|
}
|
|
87
112
|
|
|
113
|
+
.ml-search {
|
|
114
|
+
grid-column: 1 / -1;
|
|
115
|
+
border-bottom: 1px solid var(--color-border);
|
|
116
|
+
padding: 2px var(--gap-xs);
|
|
117
|
+
background: var(--color-bg-surface);
|
|
118
|
+
}
|
|
119
|
+
|
|
88
120
|
#search-input {
|
|
89
121
|
padding: var(--gap-xs) var(--gap-sm);
|
|
90
122
|
border: 1px solid var(--color-border);
|
|
91
123
|
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
|
92
|
-
background: var(--color-bg
|
|
124
|
+
background: var(--color-bg);
|
|
93
125
|
color: var(--color-text);
|
|
94
126
|
font-size: var(--font-size-sm);
|
|
95
|
-
|
|
127
|
+
flex: 1;
|
|
96
128
|
|
|
97
129
|
&::placeholder { color: var(--color-text-muted); }
|
|
98
130
|
&:focus { outline: 1px solid var(--color-accent); border-color: var(--color-accent); }
|
|
@@ -218,7 +250,7 @@
|
|
|
218
250
|
.message-list {
|
|
219
251
|
display: grid;
|
|
220
252
|
grid-template-columns: 1.2em minmax(120px, 200px) auto 1fr;
|
|
221
|
-
grid-template-rows: auto 1fr;
|
|
253
|
+
grid-template-rows: auto auto 1fr;
|
|
222
254
|
column-gap: var(--gap-sm);
|
|
223
255
|
overflow: hidden;
|
|
224
256
|
border-right: 1px solid var(--color-border);
|
|
@@ -344,7 +376,7 @@
|
|
|
344
376
|
|
|
345
377
|
.mv-header-top { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--gap-sm); }
|
|
346
378
|
.mv-header-info { flex: 1; min-width: 0; }
|
|
347
|
-
.mv-header-actions { display: flex; align-items:
|
|
379
|
+
.mv-header-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex-shrink: 0; }
|
|
348
380
|
.mv-from { font-weight: 600; }
|
|
349
381
|
.mv-to { color: var(--color-text-muted); }
|
|
350
382
|
.mv-subject { font-size: var(--font-size-lg); font-weight: 600; margin-top: var(--gap-xs); }
|
|
@@ -367,6 +399,20 @@
|
|
|
367
399
|
white-space: nowrap;
|
|
368
400
|
}
|
|
369
401
|
.mv-action:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
402
|
+
.mv-details {
|
|
403
|
+
font-size: var(--font-size-sm);
|
|
404
|
+
color: var(--color-text-muted);
|
|
405
|
+
border-top: 1px solid var(--color-border);
|
|
406
|
+
padding-top: var(--gap-xs);
|
|
407
|
+
margin-top: var(--gap-xs);
|
|
408
|
+
line-height: 1.6;
|
|
409
|
+
}
|
|
410
|
+
.mv-details-label {
|
|
411
|
+
display: inline-block;
|
|
412
|
+
min-width: 7em;
|
|
413
|
+
color: var(--color-text);
|
|
414
|
+
font-weight: 600;
|
|
415
|
+
}
|
|
370
416
|
.mv-action-primary {
|
|
371
417
|
background: var(--color-brand-dark) !important;
|
|
372
418
|
color: white !important;
|
|
@@ -474,6 +520,8 @@
|
|
|
474
520
|
border-radius: var(--radius-sm);
|
|
475
521
|
font-size: var(--font-size-sm);
|
|
476
522
|
cursor: pointer;
|
|
523
|
+
color: var(--color-text);
|
|
524
|
+
text-decoration: none;
|
|
477
525
|
|
|
478
526
|
&:hover { background: var(--color-bg-hover); }
|
|
479
527
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.36",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.7",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.11",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.2",
|
|
@@ -160,7 +160,37 @@ export function createApiRouter(db, imapManager) {
|
|
|
160
160
|
}
|
|
161
161
|
return String(v);
|
|
162
162
|
};
|
|
163
|
-
|
|
163
|
+
// Get the real Delivered-To, skipping relay domains from account config
|
|
164
|
+
const msgSettings = loadSettings();
|
|
165
|
+
const acctConfig = msgSettings.accounts.find((a) => a.id === accountId);
|
|
166
|
+
const relayDomains = acctConfig?.relayDomains || [];
|
|
167
|
+
const prefixes = acctConfig?.deliveredToPrefix || [];
|
|
168
|
+
const rawDelivered = parsed2.headers.get("delivered-to");
|
|
169
|
+
if (rawDelivered) {
|
|
170
|
+
const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
|
|
171
|
+
for (let i = deliveredList.length - 1; i >= 0; i--) {
|
|
172
|
+
const d = deliveredList[i];
|
|
173
|
+
const addr = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
174
|
+
if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
|
|
175
|
+
deliveredTo = addr;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!deliveredTo && deliveredList.length > 0) {
|
|
180
|
+
const d = deliveredList[deliveredList.length - 1];
|
|
181
|
+
deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
182
|
+
}
|
|
183
|
+
// Strip prefix from local part to get clean alias
|
|
184
|
+
if (deliveredTo && prefixes.length > 0) {
|
|
185
|
+
const [local, domain] = deliveredTo.split("@");
|
|
186
|
+
for (const prefix of prefixes) {
|
|
187
|
+
if (local.startsWith(prefix)) {
|
|
188
|
+
deliveredTo = `${local.slice(prefix.length)}@${domain}`;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
164
194
|
returnPath = hdr("return-path").replace(/[<>]/g, "");
|
|
165
195
|
// mailparser merges List-* headers into a "list" object
|
|
166
196
|
const listHeaders = parsed2.headers.get("list");
|
|
@@ -428,6 +458,153 @@ export function createApiRouter(db, imapManager) {
|
|
|
428
458
|
res.status(500).json({ error: e.message });
|
|
429
459
|
}
|
|
430
460
|
});
|
|
461
|
+
// ── Folder management ──
|
|
462
|
+
// Create subfolder
|
|
463
|
+
router.post("/folder/:accountId", async (req, res) => {
|
|
464
|
+
try {
|
|
465
|
+
const { accountId } = req.params;
|
|
466
|
+
const { parentPath, name } = req.body;
|
|
467
|
+
const fullPath = parentPath ? `${parentPath}.${name}` : name;
|
|
468
|
+
const client = imapManager.createPublicClient(accountId);
|
|
469
|
+
try {
|
|
470
|
+
await client.createmailbox(fullPath);
|
|
471
|
+
await imapManager.syncFolders(accountId, client);
|
|
472
|
+
await client.logout();
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
try {
|
|
476
|
+
await client.logout();
|
|
477
|
+
}
|
|
478
|
+
catch { /* */ }
|
|
479
|
+
}
|
|
480
|
+
res.json({ ok: true });
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
res.status(500).json({ error: e.message });
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
// Rename folder
|
|
487
|
+
router.post("/folder/:accountId/:folderId/rename", async (req, res) => {
|
|
488
|
+
try {
|
|
489
|
+
const { accountId, folderId } = req.params;
|
|
490
|
+
const { newName } = req.body;
|
|
491
|
+
const folder = db.getFolders(accountId).find(f => f.id === Number(folderId));
|
|
492
|
+
if (!folder)
|
|
493
|
+
return res.status(404).json({ error: "Folder not found" });
|
|
494
|
+
const parts = folder.path.split(folder.delimiter || ".");
|
|
495
|
+
parts[parts.length - 1] = newName;
|
|
496
|
+
const newPath = parts.join(folder.delimiter || ".");
|
|
497
|
+
const client = imapManager.createPublicClient(accountId);
|
|
498
|
+
try {
|
|
499
|
+
// iflow doesn't have renamemailbox yet — use raw imapflow
|
|
500
|
+
await client.withConnection(async () => {
|
|
501
|
+
await client.client.mailboxRename(folder.path, newPath);
|
|
502
|
+
});
|
|
503
|
+
await imapManager.syncFolders(accountId, client);
|
|
504
|
+
await client.logout();
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
try {
|
|
508
|
+
await client.logout();
|
|
509
|
+
}
|
|
510
|
+
catch { /* */ }
|
|
511
|
+
}
|
|
512
|
+
res.json({ ok: true });
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
res.status(500).json({ error: e.message });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
// Delete folder
|
|
519
|
+
router.delete("/folder/:accountId/:folderId", async (req, res) => {
|
|
520
|
+
try {
|
|
521
|
+
const { accountId, folderId } = req.params;
|
|
522
|
+
const folder = db.getFolders(accountId).find(f => f.id === Number(folderId));
|
|
523
|
+
if (!folder)
|
|
524
|
+
return res.status(404).json({ error: "Folder not found" });
|
|
525
|
+
const client = imapManager.createPublicClient(accountId);
|
|
526
|
+
try {
|
|
527
|
+
await client.withConnection(async () => {
|
|
528
|
+
await client.client.mailboxDelete(folder.path);
|
|
529
|
+
});
|
|
530
|
+
db.deleteFolder(Number(folderId));
|
|
531
|
+
await client.logout();
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
try {
|
|
535
|
+
await client.logout();
|
|
536
|
+
}
|
|
537
|
+
catch { /* */ }
|
|
538
|
+
}
|
|
539
|
+
res.json({ ok: true });
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
res.status(500).json({ error: e.message });
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
// Mark all messages in folder as read
|
|
546
|
+
router.post("/folder/:accountId/:folderId/mark-read", async (req, res) => {
|
|
547
|
+
try {
|
|
548
|
+
const { accountId, folderId } = req.params;
|
|
549
|
+
db.markFolderRead(Number(folderId));
|
|
550
|
+
res.json({ ok: true });
|
|
551
|
+
}
|
|
552
|
+
catch (e) {
|
|
553
|
+
res.status(500).json({ error: e.message });
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
// Empty folder (permanently delete all messages)
|
|
557
|
+
router.post("/folder/:accountId/:folderId/empty", async (req, res) => {
|
|
558
|
+
try {
|
|
559
|
+
const { accountId, folderId } = req.params;
|
|
560
|
+
const folder = db.getFolders(accountId).find(f => f.id === Number(folderId));
|
|
561
|
+
if (!folder)
|
|
562
|
+
return res.status(404).json({ error: "Folder not found" });
|
|
563
|
+
db.deleteAllMessages(accountId, Number(folderId));
|
|
564
|
+
// Also delete on IMAP
|
|
565
|
+
const client = imapManager.createPublicClient(accountId);
|
|
566
|
+
try {
|
|
567
|
+
const uids = await client.getUids(folder.path);
|
|
568
|
+
for (const uid of uids) {
|
|
569
|
+
await client.deleteMessageByUid(folder.path, uid);
|
|
570
|
+
}
|
|
571
|
+
await client.logout();
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
try {
|
|
575
|
+
await client.logout();
|
|
576
|
+
}
|
|
577
|
+
catch { /* */ }
|
|
578
|
+
}
|
|
579
|
+
res.json({ ok: true });
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
res.status(500).json({ error: e.message });
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
// ── Attachments ──
|
|
586
|
+
router.get("/message/:accountId/:uid/attachment/:attachmentId", async (req, res) => {
|
|
587
|
+
try {
|
|
588
|
+
const { accountId, uid, attachmentId } = req.params;
|
|
589
|
+
const folderId = req.query.folderId ? Number(req.query.folderId) : undefined;
|
|
590
|
+
const envelope = db.getMessageByUid(accountId, Number(uid), folderId);
|
|
591
|
+
if (!envelope)
|
|
592
|
+
return res.status(404).json({ error: "Message not found" });
|
|
593
|
+
const raw = await imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
594
|
+
if (!raw)
|
|
595
|
+
return res.status(404).json({ error: "Message body not available" });
|
|
596
|
+
const parsed = await simpleParser(raw);
|
|
597
|
+
const att = parsed.attachments?.[Number(attachmentId)];
|
|
598
|
+
if (!att)
|
|
599
|
+
return res.status(404).json({ error: "Attachment not found" });
|
|
600
|
+
res.set("Content-Type", att.contentType || "application/octet-stream");
|
|
601
|
+
res.set("Content-Disposition", `inline; filename="${(att.filename || "attachment").replace(/"/g, "")}""`);
|
|
602
|
+
res.send(att.content);
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
res.status(500).json({ error: e.message });
|
|
606
|
+
}
|
|
607
|
+
});
|
|
431
608
|
// ── Drafts ──
|
|
432
609
|
router.post("/draft", async (req, res) => {
|
|
433
610
|
try {
|
|
@@ -171,7 +171,37 @@ export async function getMessage(params) {
|
|
|
171
171
|
}
|
|
172
172
|
return String(v);
|
|
173
173
|
};
|
|
174
|
-
|
|
174
|
+
// Get the real Delivered-To, skipping relay domains from account config
|
|
175
|
+
const settings = loadSettings();
|
|
176
|
+
const acctConfig = settings.accounts.find(a => a.id === accountId);
|
|
177
|
+
const relayDomains = acctConfig?.relayDomains || [];
|
|
178
|
+
const prefixes = acctConfig?.deliveredToPrefix || [];
|
|
179
|
+
const rawDelivered = parsed.headers.get("delivered-to");
|
|
180
|
+
if (rawDelivered) {
|
|
181
|
+
const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
|
|
182
|
+
for (let i = deliveredList.length - 1; i >= 0; i--) {
|
|
183
|
+
const d = deliveredList[i];
|
|
184
|
+
const addr = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
185
|
+
if (!relayDomains.some((rd) => addr.includes(`@${rd}`))) {
|
|
186
|
+
deliveredTo = addr;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!deliveredTo && deliveredList.length > 0) {
|
|
191
|
+
const d = deliveredList[deliveredList.length - 1];
|
|
192
|
+
deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
193
|
+
}
|
|
194
|
+
// Strip prefix from local part to get clean alias
|
|
195
|
+
if (deliveredTo && prefixes.length > 0) {
|
|
196
|
+
const [local, domain] = deliveredTo.split("@");
|
|
197
|
+
for (const prefix of prefixes) {
|
|
198
|
+
if (local.startsWith(prefix)) {
|
|
199
|
+
deliveredTo = `${local.slice(prefix.length)}@${domain}`;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
175
205
|
returnPath = hdr("return-path").replace(/[<>]/g, "");
|
|
176
206
|
// mailparser merges List-* headers into a "list" object
|
|
177
207
|
const listHeaders = parsed.headers.get("list");
|
|
@@ -34,6 +34,8 @@ export declare class ImapManager extends EventEmitter {
|
|
|
34
34
|
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
35
35
|
/** Search messages on the IMAP server — returns matching UIDs */
|
|
36
36
|
searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
|
|
37
|
+
/** Create a fresh ImapClient for an account (public access for API endpoints) */
|
|
38
|
+
createPublicClient(accountId: string): ImapClient;
|
|
37
39
|
/** Create a fresh ImapClient for an account (disposable, single-use) */
|
|
38
40
|
private createClient;
|
|
39
41
|
/** Register an account */
|
|
@@ -89,6 +89,10 @@ export class ImapManager extends EventEmitter {
|
|
|
89
89
|
catch { /* ignore */ }
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
/** Create a fresh ImapClient for an account (public access for API endpoints) */
|
|
93
|
+
createPublicClient(accountId) {
|
|
94
|
+
return this.createClient(accountId);
|
|
95
|
+
}
|
|
92
96
|
/** Create a fresh ImapClient for an account (disposable, single-use) */
|
|
93
97
|
createClient(accountId) {
|
|
94
98
|
const config = this.configs.get(accountId);
|
|
@@ -127,12 +127,24 @@ const DEFAULT_ALLOWLIST = {
|
|
|
127
127
|
// ── Public API ──
|
|
128
128
|
/** Load account configs */
|
|
129
129
|
export function loadAccounts() {
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
130
|
+
const sharedDir = getSharedDir();
|
|
131
|
+
const sharedPath = path.join(sharedDir, "accounts.jsonc");
|
|
132
|
+
const localPath = path.join(LOCAL_DIR, "accounts.jsonc");
|
|
133
|
+
// Try shared first, then local cache
|
|
134
|
+
let accounts = readJsonc(sharedPath);
|
|
135
|
+
if (!accounts)
|
|
136
|
+
accounts = readJsonc(localPath);
|
|
137
|
+
if (accounts?.accounts || Array.isArray(accounts)) {
|
|
138
|
+
// Cache shared to local for offline fallback
|
|
139
|
+
if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
|
|
140
|
+
try {
|
|
141
|
+
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
142
|
+
fs.writeFileSync(localPath, fs.readFileSync(sharedPath, "utf-8"));
|
|
143
|
+
}
|
|
144
|
+
catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
return accounts.accounts || accounts;
|
|
147
|
+
}
|
|
136
148
|
// Legacy: read from settings.jsonc
|
|
137
149
|
const legacy = loadLegacySettings();
|
|
138
150
|
if (legacy?.accounts)
|
|
@@ -19,6 +19,8 @@ export declare class MailxDB {
|
|
|
19
19
|
upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
|
|
20
20
|
getFolders(accountId: string): Folder[];
|
|
21
21
|
deleteFolder(folderId: number): void;
|
|
22
|
+
markFolderRead(folderId: number): void;
|
|
23
|
+
deleteAllMessages(accountId: string, folderId: number): void;
|
|
22
24
|
updateFolderCounts(folderId: number, total: number, unread: number): void;
|
|
23
25
|
updateFolderSync(folderId: number, uidvalidity: number, highestModseq: string): void;
|
|
24
26
|
getFolderSync(folderId: number): {
|
|
@@ -168,6 +168,14 @@ export class MailxDB {
|
|
|
168
168
|
this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
|
|
169
169
|
this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
|
|
170
170
|
}
|
|
171
|
+
markFolderRead(folderId) {
|
|
172
|
+
this.db.prepare(`UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]') WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`).run(folderId);
|
|
173
|
+
this.recalcFolderCounts(folderId);
|
|
174
|
+
}
|
|
175
|
+
deleteAllMessages(accountId, folderId) {
|
|
176
|
+
this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
|
|
177
|
+
this.recalcFolderCounts(folderId);
|
|
178
|
+
}
|
|
171
179
|
updateFolderCounts(folderId, total, unread) {
|
|
172
180
|
this.db.prepare("UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?").run(total, unread, folderId);
|
|
173
181
|
}
|
|
@@ -30,6 +30,8 @@ export interface AccountConfig {
|
|
|
30
30
|
};
|
|
31
31
|
enabled: boolean;
|
|
32
32
|
defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
|
|
33
|
+
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|
|
34
|
+
deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
|
|
33
35
|
}
|
|
34
36
|
/** Standard IMAP special-use folder types */
|
|
35
37
|
export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
|