@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 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
- document.title = `mailx - ${folderName}`;
104
+ setTitle(`mailx - ${folderName}`);
20
105
  }, () => {
21
106
  // Unified inbox handler
22
107
  currentFolderSpecialUse = "inbox";
23
108
  loadUnifiedInbox();
24
- document.title = "mailx - All Inboxes";
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
- document.title = `mailx - Search: ${query}`;
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 (const att of msg.attachments) {
219
- const chip = document.createElement("span");
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
- if (retryCount < 3) {
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">Failed to load message: ${err}</div>`;
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-surface);
124
+ background: var(--color-bg);
93
125
  color: var(--color-text);
94
126
  font-size: var(--font-size-sm);
95
- width: 500px;
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: center; gap: var(--gap-xs); flex-shrink: 0; }
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.35",
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.6",
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
- deliveredTo = hdr("delivered-to");
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
- deliveredTo = hdr("delivered-to");
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
- // Try new split file first
131
- const accounts = readJsonc(path.join(getSharedDir(), "accounts.jsonc"));
132
- if (accounts?.accounts)
133
- return accounts.accounts;
134
- if (Array.isArray(accounts))
135
- return accounts;
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";