@bobfrankston/mailx 1.0.405 → 1.0.407

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.
@@ -151,16 +151,6 @@
151
151
  <span class="ml-col ml-col-date" data-sort="date">Date</span>
152
152
  <span class="ml-col ml-col-subject">Subject</span>
153
153
  </div>
154
- <div class="ml-bulkbar" id="ml-bulkbar" hidden>
155
- <button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select">✕</button>
156
- <span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
157
- <span style="flex:1"></span>
158
- <button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
159
- <button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
160
- <button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
161
- <button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
162
- <button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete">🗑</button>
163
- </div>
164
154
  <div class="ml-body" id="ml-body">
165
155
  <div class="ml-empty">Select a folder to view messages</div>
166
156
  </div>
package/client/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * mailx client entry point.
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
- import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
5
+ import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
8
8
  import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
@@ -891,6 +891,11 @@ document.addEventListener("mailx-moved", (e) => {
891
891
  undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
892
892
  });
893
893
  document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
894
+ // Same handlers also bound to the top-toolbar icons so delete/spam work
895
+ // regardless of whether a message is open in the viewer. Useful for quick
896
+ // triage from a list-only view.
897
+ document.getElementById("btn-tb-delete")?.addEventListener("click", deleteSelectedMessages);
898
+ document.getElementById("btn-tb-spam")?.addEventListener("click", spamSelectedMessages);
894
899
  // ── Flag toggle ──
895
900
  document.getElementById("btn-flag")?.addEventListener("click", async () => {
896
901
  const sel = messageState.getSelected();
@@ -1404,6 +1409,11 @@ window.addEventListener("message", (e) => {
1404
1409
  menu.style.cssText = `position:fixed;z-index:2400;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:180px;`;
1405
1410
  menu.style.left = `${Math.min(x, window.innerWidth - 200)}px`;
1406
1411
  menu.style.top = `${Math.min(y, window.innerHeight - 200)}px`;
1412
+ // mousedown inside the menu must NOT reach the document-level
1413
+ // dismiss handler — otherwise the menu is removed before click
1414
+ // fires on the row and the action silently no-ops (user report
1415
+ // 2026-04-24). Stop propagation at the menu root covers every row.
1416
+ menu.addEventListener("mousedown", (ev) => ev.stopPropagation());
1407
1417
  for (const it of items) {
1408
1418
  const row = document.createElement("div");
1409
1419
  row.textContent = it.label;
@@ -2651,6 +2661,16 @@ optFolderCounts?.addEventListener("change", () => {
2651
2661
  }
2652
2662
  localStorage.setItem("mailx-folder-counts", String(optFolderCounts.checked));
2653
2663
  });
2664
+ // Q52: Reset column widths — clears persisted list/viewer splitter and
2665
+ // restores the default CSS-var value. Currently only the list/viewer split
2666
+ // is user-resizable; if per-column drag-resize lands later, add its keys to
2667
+ // the cleanup list below.
2668
+ document.getElementById("btn-reset-widths")?.addEventListener("click", () => {
2669
+ localStorage.removeItem("mailx-split");
2670
+ document.documentElement.style.removeProperty("--list-viewer-split");
2671
+ if (viewDropdown)
2672
+ viewDropdown.hidden = true;
2673
+ });
2654
2674
  // ── Settings menu ──
2655
2675
  const settingsBtn = document.getElementById("btn-settings");
2656
2676
  const settingsDropdown = document.getElementById("settings-dropdown");
@@ -2819,6 +2839,9 @@ else
2819
2839
  // the user staring at stale numbers. Idempotent — renderOutboxStatus just
2820
2840
  // overwrites the text.
2821
2841
  function renderOutboxStatus(s) {
2842
+ // Feed the folder-tree synthesized "Send-pending" row. Idempotent —
2843
+ // it no-ops when the presence state and count haven't changed.
2844
+ setOutboxTotal(s?.total || 0);
2822
2845
  const el = document.getElementById("status-queue");
2823
2846
  if (!el)
2824
2847
  return;
@@ -120,6 +120,19 @@ function renderEvents(events) {
120
120
  // Click-to-open — interim per user 2026-04-23: route to Google Calendar's
121
121
  // web UI via openExternal until we build an in-app event editor.
122
122
  // Right-click gives a context menu with "View in browser" explicit.
123
+ // Item 17: on Android, openCalendarEvent redirects to the native Calendar
124
+ // app via ACTION_VIEW; desktop still falls through to the browser.
125
+ const openInCalendar = (url) => {
126
+ const api = window.mailxapi;
127
+ if (api?.openCalendarEvent) {
128
+ api.openCalendarEvent(url);
129
+ return;
130
+ }
131
+ if (api?.openExternal)
132
+ api.openExternal(url);
133
+ else
134
+ window.open(url, "_blank");
135
+ };
123
136
  const openInBrowser = (url) => {
124
137
  const api = window.mailxapi;
125
138
  if (api?.openExternal)
@@ -131,7 +144,7 @@ function renderEvents(events) {
131
144
  el.addEventListener("click", () => {
132
145
  const link = el.dataset.link;
133
146
  if (link)
134
- openInBrowser(link);
147
+ openInCalendar(link);
135
148
  });
136
149
  el.addEventListener("contextmenu", (e) => {
137
150
  e.preventDefault();
@@ -283,6 +296,30 @@ export function initCalendarSidebar() {
283
296
  await createTask({ title });
284
297
  renderTasks();
285
298
  });
299
+ // Manual refresh — getTasks on the service side already fires a Google
300
+ // pull under the hood on every call, but the visible feedback (spinning
301
+ // glyph for ~600 ms, disabled while in flight) makes it explicit that
302
+ // the user asked for a fresh pull, not a cached redraw.
303
+ wireOnce("cal-side-refresh-tasks", async () => {
304
+ const btn = document.getElementById("cal-side-refresh-tasks");
305
+ if (btn?.classList.contains("cal-side-refreshing"))
306
+ return;
307
+ btn?.classList.add("cal-side-refreshing");
308
+ btn.disabled = true;
309
+ try {
310
+ // renderTasks() calls getTasks() on the service which triggers
311
+ // the async Google pull; the subsequent tasksUpdated event
312
+ // re-renders with the merged result.
313
+ await renderTasks();
314
+ }
315
+ finally {
316
+ setTimeout(() => {
317
+ btn?.classList.remove("cal-side-refreshing");
318
+ if (btn)
319
+ btn.disabled = false;
320
+ }, 600);
321
+ }
322
+ });
286
323
  const showDoneCb = document.getElementById("cal-side-show-done");
287
324
  if (showDoneCb && !showDoneCb.__wired) {
288
325
  showDoneCb.__wired = true;
@@ -424,6 +424,31 @@ export function initFolderTree(container, handler, unifiedHandler) {
424
424
  onUnifiedInbox = unifiedHandler || null;
425
425
  loadFolderTree(container);
426
426
  }
427
+ // Item 12: outbox total drives a synthesized "Send-pending" row at the top
428
+ // of the folder tree. The server pushes outboxStatus on every mutation; when
429
+ // the total flips between zero and non-zero we re-render so the row appears
430
+ // / disappears without waiting for the next full refresh.
431
+ let lastOutboxTotal = 0;
432
+ export function setOutboxTotal(total) {
433
+ const prev = lastOutboxTotal;
434
+ lastOutboxTotal = total | 0;
435
+ // Zero → zero: nothing to render, nothing to clear.
436
+ if (prev === 0 && lastOutboxTotal === 0)
437
+ return;
438
+ const existing = document.getElementById("ft-send-pending");
439
+ // Non-zero in both → just update the badge text; avoid a full re-render.
440
+ if (prev > 0 && lastOutboxTotal > 0 && existing) {
441
+ const badge = existing.querySelector(".ft-badge");
442
+ if (badge)
443
+ badge.textContent = String(lastOutboxTotal);
444
+ existing.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? "" : "s"} queued for send`;
445
+ return;
446
+ }
447
+ // Presence flipped (0→N or N→0) — re-render to insert / remove the row.
448
+ const container = document.getElementById("folder-tree");
449
+ if (container)
450
+ loadFolderTree(container);
451
+ }
427
452
  async function loadFolderTree(container) {
428
453
  // Show loading state while preserving existing tree (if any) on refresh
429
454
  const hadContent = container.children.length > 0 && !container.querySelector(".folder-loading");
@@ -735,6 +760,25 @@ async function loadFolderTree(container) {
735
760
  });
736
761
  fragment.appendChild(unifiedEl);
737
762
  }
763
+ // Item 12: Send-pending virtual row — synthesized from the outbox
764
+ // queue, only shown when something is actually queued. Clicking
765
+ // opens the outbox-view modal (pink rows, cancellable). Lives at
766
+ // the top of the tree so a stuck send is impossible to miss.
767
+ if (lastOutboxTotal > 0) {
768
+ const pendingEl = document.createElement("div");
769
+ pendingEl.className = "ft-folder ft-unified ft-send-pending";
770
+ pendingEl.id = "ft-send-pending";
771
+ pendingEl.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? "" : "s"} queued for send`;
772
+ pendingEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">Send-pending</span><span class="ft-badge ft-badge-outbox">${lastOutboxTotal}</span>`;
773
+ pendingEl.addEventListener("click", async () => {
774
+ try {
775
+ const { openOutboxView } = await import("./outbox-view.js");
776
+ openOutboxView();
777
+ }
778
+ catch { /* outbox-view load failed — silent is OK, status pill still works */ }
779
+ });
780
+ fragment.appendChild(pendingEl);
781
+ }
738
782
  for (const { account, folders } of accountFolderData) {
739
783
  const accountEl = document.createElement("div");
740
784
  accountEl.className = "ft-account";
@@ -121,27 +121,11 @@ function exitMultiSelect() {
121
121
  clearSelection();
122
122
  updateBulkBar();
123
123
  }
124
- /** Refresh the bulk-actions bar visibility + "N selected" label. Called
125
- * whenever selection or mode changes. Visible either when 2+ rows are
126
- * selected (desktop Ctrl/Shift-click multi-selection) OR when touch
127
- * multi-select mode is active (even with a single row, so the user sees
128
- * the bar as the mode indicator). */
129
- function updateBulkBar() {
130
- const body = document.getElementById("ml-body");
131
- const bar = document.getElementById("ml-bulkbar");
132
- const count = document.getElementById("ml-bulk-count");
133
- if (!body || !bar || !count)
134
- return;
135
- const active = body.classList.contains("multi-select-on");
136
- const n = body.querySelectorAll(".ml-row.selected").length;
137
- if (n >= 2 || (active && n > 0)) {
138
- bar.hidden = false;
139
- count.textContent = `${n} selected`;
140
- }
141
- else {
142
- bar.hidden = true;
143
- }
144
- }
124
+ /** Bulk-actions bar retired 2026-04-24 — trash + spam live on the main
125
+ * toolbar now and every other bulk op (mark-read, flag, move) is one
126
+ * right-click away. Kept as a no-op stub so existing call sites
127
+ * (avatar tap, row click, long-press) don't need to be touched. */
128
+ function updateBulkBar() { }
145
129
  // Escape key + click-outside-list exit multi-select mode. Attached once
146
130
  // (idempotent because document only has one listener scope per handler).
147
131
  if (!window.__mailxMultiSelectWired) {
@@ -156,89 +140,10 @@ if (!window.__mailxMultiSelectWired) {
156
140
  return;
157
141
  const target = e.target;
158
142
  // A tap on a row is handled by the row's own click listener; only
159
- // exit when the tap is on neutral ground (outside the list entirely
160
- // and not on the bulk bar).
161
- if (!target.closest(".ml-row") && !target.closest(".ml-bulkbar"))
143
+ // exit when the tap is on neutral ground (outside the list entirely).
144
+ if (!target.closest(".ml-row"))
162
145
  exitMultiSelect();
163
146
  }, true);
164
- // Wire bulk-bar buttons — each delegates to the single-message handlers
165
- // but iterates over every .ml-row.selected in the body.
166
- document.addEventListener("click", async (e) => {
167
- const target = e.target;
168
- if (target.closest("#ml-bulk-cancel")) {
169
- exitMultiSelect();
170
- return;
171
- }
172
- const btn = target.closest(".ml-bulk-btn");
173
- if (!btn)
174
- return;
175
- const op = btn.dataset.bulk;
176
- const body = document.getElementById("ml-body");
177
- if (!body?.classList.contains("multi-select-on"))
178
- return;
179
- const selected = getSelectedMessages();
180
- if (selected.length === 0)
181
- return;
182
- try {
183
- if (op === "delete") {
184
- // Delegate to the existing Del-key handler so the same
185
- // tombstone/undo logic runs for bulk deletes.
186
- document.dispatchEvent(new CustomEvent("mailx-delete"));
187
- }
188
- else if (op === "flag") {
189
- for (const m of selected) {
190
- const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
191
- if (!row)
192
- continue;
193
- const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
194
- if (!msgData)
195
- continue;
196
- const isFlagged = msgData.flags?.includes("\\Flagged");
197
- const newFlags = isFlagged
198
- ? msgData.flags.filter((f) => f !== "\\Flagged")
199
- : [...(msgData.flags || []), "\\Flagged"];
200
- await updateFlags(m.accountId, m.uid, newFlags);
201
- msgData.flags = newFlags;
202
- state.updateMessageFlags(m.accountId, m.uid, newFlags);
203
- row.classList.toggle("flagged");
204
- }
205
- }
206
- else if (op === "markread") {
207
- for (const m of selected) {
208
- const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
209
- if (!row)
210
- continue;
211
- const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
212
- if (!msgData)
213
- continue;
214
- if (msgData.flags?.includes("\\Seen"))
215
- continue;
216
- const newFlags = [...(msgData.flags || []), "\\Seen"];
217
- await updateFlags(m.accountId, m.uid, newFlags);
218
- msgData.flags = newFlags;
219
- state.updateMessageFlags(m.accountId, m.uid, newFlags);
220
- row.classList.remove("unread");
221
- }
222
- }
223
- else if (op === "move") {
224
- // Use the first selection's account/folder for the picker scope.
225
- const { accountId, folderId } = selected[0];
226
- const pick = await pickFolder(accountId, { excludeFolderIds: [folderId] });
227
- if (!pick)
228
- return;
229
- const uids = selected.map(s => s.uid);
230
- await apiMoveMessages(accountId, uids, pick.folderId);
231
- state.removeMessages(uids.map(u => ({ accountId, uid: u })));
232
- }
233
- else if (op === "spam") {
234
- document.getElementById("btn-spam")?.click();
235
- }
236
- exitMultiSelect();
237
- }
238
- catch (err) {
239
- alert(`Bulk ${op} failed: ${err.message || err}`);
240
- }
241
- });
242
147
  }
243
148
  function selectRange(from, to) {
244
149
  const body = document.getElementById("ml-body");
package/client/index.html CHANGED
@@ -31,6 +31,8 @@
31
31
  <label class="tb-menu-item" title="Show only flagged (★) messages in the list"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
32
32
  <label class="tb-menu-item" title="Show unread/total counts next to each folder in the tree"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
33
33
  <label class="tb-menu-item" title="Show the right-side calendar/tasks sidebar (Thunderbird-Lightning style)"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
34
+ <hr class="tb-menu-sep">
35
+ <button class="tb-menu-item" id="btn-reset-widths" title="Restore the default list / viewer split position">Reset column widths</button>
34
36
  </div>
35
37
  </div>
36
38
  <div class="tb-menu" id="settings-menu">
@@ -55,6 +57,8 @@
55
57
  <button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
56
58
  </div>
57
59
  </div>
60
+ <button class="tb-btn" id="btn-tb-delete" title="Delete selected message (Del)">🗑</button>
61
+ <button class="tb-btn" id="btn-tb-spam" title="Mark as spam — move to the configured Junk folder">⚠</button>
58
62
  <span id="app-version" class="app-version">mailx</span>
59
63
  </div>
60
64
  <div class="toolbar-right">
@@ -124,16 +128,6 @@
124
128
  <span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
125
129
  <span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
126
130
  </div>
127
- <div class="ml-bulkbar" id="ml-bulkbar" hidden>
128
- <button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select (Esc)">✕</button>
129
- <span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
130
- <span style="flex:1"></span>
131
- <button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
132
- <button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
133
- <button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
134
- <button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
135
- <button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete (Del)">🗑</button>
136
- </div>
137
131
  <div class="ml-body" id="ml-body">
138
132
  <div class="ml-empty">Select a folder to view messages</div>
139
133
  </div>
@@ -197,6 +191,7 @@
197
191
  <footer class="cal-side-foot">
198
192
  <div class="cal-side-task-header-row">
199
193
  <label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
194
+ <button class="cal-side-new" id="cal-side-refresh-tasks" title="Refresh tasks from Google">↻</button>
200
195
  <button class="cal-side-new" id="cal-side-new-task" title="New task">+ Task</button>
201
196
  </div>
202
197
  <div class="cal-side-tasks" id="cal-side-tasks"></div>
@@ -170,6 +170,40 @@
170
170
  return callNode("openLocalPath", { which: which });
171
171
  },
172
172
 
173
+ // Item 17: platform-aware external openers. On Android (MAUI shell)
174
+ // these fire native Intents so Contacts/Calendar links jump to the
175
+ // OS apps instead of the browser. On desktop the MAUI shell isn't
176
+ // in play; we fall through to a URL open via the navigation path
177
+ // that msger / external launcher handles the same as any http link.
178
+ openContact: function(email) {
179
+ try {
180
+ // MAUI intercepts this scheme in OnNavigating and fires a
181
+ // native ACTION_VIEW intent on the mailto: URI — system
182
+ // Contacts registers for it on stock Android.
183
+ if (/Android/i.test(navigator.userAgent)) {
184
+ window.location.href = "mailxapi-intent://contact/" + encodeURIComponent(email);
185
+ return;
186
+ }
187
+ } catch (e) { /* navigation blocked — fall through */ }
188
+ // Desktop / browser fallback: open mailto: — msger forwards
189
+ // mailto URLs to the system default handler, which on a mailx
190
+ // install is mailx itself (our own compose window takes over).
191
+ window.location.href = "mailto:" + email;
192
+ },
193
+ openCalendarEvent: function(htmlLink) {
194
+ if (!htmlLink) return;
195
+ try {
196
+ if (/Android/i.test(navigator.userAgent)) {
197
+ // MAUI dispatches this to the native Calendar app via
198
+ // ACTION_VIEW on the Google Calendar event URL.
199
+ window.location.href = "mailxapi-intent://calendar/" + encodeURIComponent(htmlLink);
200
+ return;
201
+ }
202
+ } catch (e) { /* */ }
203
+ // Desktop: existing openExternal path (browser, new tab).
204
+ window.open(htmlLink, "_blank");
205
+ },
206
+
173
207
  // Sync
174
208
  syncAll: function() { return callNode("syncAll"); },
175
209
  syncAccount: function(accountId) { return callNode("syncAccount", { accountId: accountId }); },
@@ -188,6 +188,17 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
188
188
  color: var(--color-accent);
189
189
  }
190
190
 
191
+ /* Item 12: Send-pending virtual row — pink-accented to match the pink-row
192
+ reconciliation state used elsewhere for local-only / not-yet-sent rows. */
193
+ .ft-send-pending {
194
+ color: oklch(0.55 0.18 25);
195
+ &::before {
196
+ content: "✉";
197
+ margin-right: 4px;
198
+ font-size: 0.9em;
199
+ }
200
+ }
201
+
191
202
  .ft-folder {
192
203
  display: flex;
193
204
  align-items: center;
@@ -1107,39 +1118,9 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
1107
1118
  }
1108
1119
  .alarm-btn-primary:hover { filter: brightness(1.1); }
1109
1120
 
1110
- /* Bulk-actions bar appears over the list header when multi-select mode is
1111
- active. Shows "N selected" + Mark-read / Flag / Move / Spam / Delete
1112
- buttons + Cancel. Kept in sibling position to ml-header so it uses the
1113
- same horizontal space. */
1114
- .ml-bulkbar {
1115
- display: flex;
1116
- align-items: center;
1117
- gap: var(--gap-sm);
1118
- padding: var(--gap-xs) var(--gap-sm);
1119
- background: var(--color-brand, oklch(0.65 0.14 250));
1120
- color: #fff;
1121
- font-size: var(--font-size-sm);
1122
- border-bottom: 1px solid var(--color-border);
1123
- grid-column: 1 / -1;
1124
- }
1125
- .ml-bulk-count { font-weight: 500; font-variant-numeric: tabular-nums; }
1126
- .ml-bulk-cancel,
1127
- .ml-bulk-btn {
1128
- border: 0;
1129
- background: transparent;
1130
- color: inherit;
1131
- cursor: pointer;
1132
- padding: 0.25em 0.55em;
1133
- border-radius: 4px;
1134
- font-size: 1rem;
1135
- }
1136
- .ml-bulk-cancel:hover,
1137
- .ml-bulk-btn:hover {
1138
- background: oklch(1 0 0 / 0.15);
1139
- }
1140
- .ml-bulk-btn.ml-bulk-danger:hover {
1141
- background: oklch(0.65 0.22 25 / 0.4);
1142
- }
1121
+ /* Bulk-actions bar retired 2026-04-24 trash + spam moved to the main
1122
+ toolbar; other bulk ops are reachable via right-click context menu.
1123
+ CSS removed so future readers aren't confused by dead selectors. */
1143
1124
 
1144
1125
  /* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
1145
1126
  desktop). Add a left-edge accent bar so it's visually clear the list is
@@ -1356,6 +1337,10 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
1356
1337
  label { flex: 1; }
1357
1338
  .cal-side-new { width: auto; padding: 2px 8px; font-size: 0.85em; }
1358
1339
  }
1340
+ /* Refresh button visual feedback — the ↻ glyph spins for ~600 ms when a
1341
+ pull is in flight so the user sees that their click did something. */
1342
+ @keyframes cal-side-spin { to { transform: rotate(360deg); } }
1343
+ .cal-side-refreshing { animation: cal-side-spin 0.6s linear; pointer-events: none; }
1359
1344
 
1360
1345
  .ml-empty {
1361
1346
  grid-column: 1 / -1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.405",
3
+ "version": "1.0.407",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -8,7 +8,18 @@
8
8
  "mailx": "bin/mailx.js"
9
9
  },
10
10
  "workspaces": [
11
- "packages/*",
11
+ "packages/mailx-types",
12
+ "packages/mailx-host",
13
+ "packages/mailx-send",
14
+ "packages/mailx-compose",
15
+ "packages/mailx-settings",
16
+ "packages/mailx-store",
17
+ "packages/mailx-store-web",
18
+ "packages/mailx-imap",
19
+ "packages/mailx-service",
20
+ "packages/mailx-api",
21
+ "packages/mailx-core",
22
+ "packages/mailx-server",
12
23
  "client"
13
24
  ],
14
25
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-host",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Host abstraction for mailx — dispatches to msger or msgview",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { TransportFactory } from "@bobfrankston/tcp-transport";
7
7
  import { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
8
- import type { AccountConfig, MessageEnvelope, Folder } from "@bobfrankston/mailx-types";
8
+ import type { AccountConfig, MessageEnvelope, EmailAddress, Folder } from "@bobfrankston/mailx-types";
9
9
  import { EventEmitter } from "node:events";
10
10
  /** Events emitted by the IMAP manager */
11
11
  export interface ImapManagerEvents {
@@ -282,6 +282,31 @@ export declare class ImapManager extends EventEmitter {
282
282
  processSyncActions(accountId: string): Promise<void>;
283
283
  /** Find a folder by specialUse, case-insensitive */
284
284
  private findFolder;
285
+ /** Optimistic local-first Sent insert: write a row into the local DB's
286
+ * Sent folder the moment the user hits Send, so the list reflects it
287
+ * immediately instead of waiting for SMTP + IMAP-APPEND + syncFolder
288
+ * (five server round-trips against a Dovecot that caps at 20 conns).
289
+ *
290
+ * Uses a synthetic negative UID so it can't collide with a real APPENDUID
291
+ * (which is always positive). When the real sync eventually picks the
292
+ * message up in Sent with the server's UID, `db.upsertMessage` spots
293
+ * the Message-ID match and rebinds the existing row's UID — no duplicate.
294
+ * Negative UID also makes the row render pink (getMessages flags uid<0
295
+ * as pending) so the user sees it's not-yet-reconciled.
296
+ *
297
+ * Best-effort — any failure path (no Sent folder yet, parse error, store
298
+ * write error) is logged and swallowed; the send itself is unaffected. */
299
+ insertOptimisticSentRow(accountId: string, envelope: {
300
+ messageId: string;
301
+ inReplyTo: string;
302
+ references: string[];
303
+ subject: string;
304
+ from: EmailAddress;
305
+ to: EmailAddress[];
306
+ cc: EmailAddress[];
307
+ bcc: EmailAddress[];
308
+ date: number;
309
+ }, rawMessage: string): Promise<void>;
285
310
  /** Copy sent message to the Sent folder via IMAP APPEND */
286
311
  copyToSent(accountId: string, rawMessage: string | Buffer): Promise<void>;
287
312
  /** Save a draft to the Drafts folder via IMAP APPEND.
@@ -2569,6 +2569,64 @@ export class ImapManager extends EventEmitter {
2569
2569
  return folders.find(f => f.specialUse === specialUse ||
2570
2570
  f.path.toLowerCase() === specialUse.toLowerCase()) || null;
2571
2571
  }
2572
+ /** Optimistic local-first Sent insert: write a row into the local DB's
2573
+ * Sent folder the moment the user hits Send, so the list reflects it
2574
+ * immediately instead of waiting for SMTP + IMAP-APPEND + syncFolder
2575
+ * (five server round-trips against a Dovecot that caps at 20 conns).
2576
+ *
2577
+ * Uses a synthetic negative UID so it can't collide with a real APPENDUID
2578
+ * (which is always positive). When the real sync eventually picks the
2579
+ * message up in Sent with the server's UID, `db.upsertMessage` spots
2580
+ * the Message-ID match and rebinds the existing row's UID — no duplicate.
2581
+ * Negative UID also makes the row render pink (getMessages flags uid<0
2582
+ * as pending) so the user sees it's not-yet-reconciled.
2583
+ *
2584
+ * Best-effort — any failure path (no Sent folder yet, parse error, store
2585
+ * write error) is logged and swallowed; the send itself is unaffected. */
2586
+ async insertOptimisticSentRow(accountId, envelope, rawMessage) {
2587
+ try {
2588
+ const sent = this.findFolder(accountId, "sent");
2589
+ if (!sent) {
2590
+ console.log(` [sent-local] no Sent folder for ${accountId}; skipping optimistic row`);
2591
+ return;
2592
+ }
2593
+ // Synthetic UID — negative ms timestamp is monotonic + won't
2594
+ // collide with server UIDs. When the real APPENDUID returns via
2595
+ // sync, upsertMessage's Message-ID rebind swaps this for the
2596
+ // real positive value.
2597
+ const synthUid = -Date.now();
2598
+ const bodyPath = await this.bodyStore.putMessage(accountId, sent.id, synthUid, Buffer.from(rawMessage, "utf-8"));
2599
+ const parsed = await extractPreview(rawMessage);
2600
+ this.db.upsertMessage({
2601
+ accountId,
2602
+ folderId: sent.id,
2603
+ uid: synthUid,
2604
+ messageId: envelope.messageId,
2605
+ inReplyTo: envelope.inReplyTo,
2606
+ references: envelope.references,
2607
+ date: envelope.date,
2608
+ subject: envelope.subject,
2609
+ from: envelope.from,
2610
+ to: envelope.to,
2611
+ cc: envelope.cc,
2612
+ flags: ["\\Seen"],
2613
+ size: rawMessage.length,
2614
+ hasAttachments: parsed.hasAttachments,
2615
+ preview: parsed.preview,
2616
+ bodyPath,
2617
+ });
2618
+ // Folder-tree badge refresh + message-list reload if the user
2619
+ // is currently on Sent — same event the sync path emits.
2620
+ this.db.recalcFolderCounts(sent.id);
2621
+ this.emit("folderCountsChanged", { accountId, folderId: sent.id });
2622
+ console.log(` [sent-local] wrote optimistic row in Sent (uid=${synthUid}) for ${accountId}: ${envelope.subject}`);
2623
+ }
2624
+ catch (e) {
2625
+ // Non-fatal — send continues, Sent folder just won't show the
2626
+ // row until the real APPEND-then-sync cycle completes.
2627
+ console.error(` [sent-local] optimistic insert failed: ${e?.message || e}`);
2628
+ }
2629
+ }
2572
2630
  /** Copy sent message to the Sent folder via IMAP APPEND */
2573
2631
  async copyToSent(accountId, rawMessage) {
2574
2632
  const sent = this.findFolder(accountId, "sent");
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  import * as gsync from "./google-sync.js";
12
12
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
13
- import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
13
+ import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
14
14
  import { simpleParser } from "mailparser";
15
15
  /** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
16
16
  * mailparser only exposes ONE of mail/url even when both are present, so we
@@ -937,8 +937,16 @@ export class MailxService {
937
937
  const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
938
938
  const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
939
939
  const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
940
- const body = msg.bodyHtml || msg.bodyText || "";
941
- const bodyEncoded = encodeQuotedPrintable(body);
940
+ // HTML-bodied mail gets a text/plain alternative part too — spam
941
+ // filters (SpamAssassin / Rspamd / Google) penalise HTML-only mail
942
+ // by 1-2 points, and plain-text-only readers still exist. The text
943
+ // part is derived from the HTML via htmlToPlainText when the caller
944
+ // didn't supply an explicit bodyText.
945
+ const hasHtml = !!msg.bodyHtml;
946
+ const htmlBody = msg.bodyHtml || "";
947
+ const textBody = msg.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
948
+ const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
949
+ const textEncoded = encodeQuotedPrintable(textBody);
942
950
  // Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
943
951
  const domain = account.email.split("@")[1] || "mailx.local";
944
952
  const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
@@ -953,21 +961,53 @@ export class MailxService {
953
961
  `MIME-Version: 1.0`,
954
962
  ].filter(h => h !== null);
955
963
  let rawMessage;
964
+ const newBoundary = () => `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
965
+ // Inner body: either a multipart/alternative (text+html) or a single
966
+ // text/plain. `innerBody` is the body-only portion (no envelope
967
+ // headers) that will be wrapped by the attachments multipart if any.
968
+ const makeInner = () => {
969
+ if (hasHtml) {
970
+ const altBoundary = newBoundary();
971
+ const body = `--${altBoundary}\r\n` +
972
+ `Content-Type: text/plain; charset=UTF-8\r\n` +
973
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
974
+ `${textEncoded}\r\n` +
975
+ `--${altBoundary}\r\n` +
976
+ `Content-Type: text/html; charset=UTF-8\r\n` +
977
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
978
+ `${htmlEncoded}\r\n` +
979
+ `--${altBoundary}--\r\n`;
980
+ return {
981
+ headers: [`Content-Type: multipart/alternative; boundary="${altBoundary}"`],
982
+ body,
983
+ };
984
+ }
985
+ // Plain-text-only send — no HTML supplied, no alternative needed.
986
+ return {
987
+ headers: [
988
+ `Content-Type: text/plain; charset=UTF-8`,
989
+ `Content-Transfer-Encoding: quoted-printable`,
990
+ ],
991
+ body: textEncoded,
992
+ };
993
+ };
956
994
  if (hasAttachments) {
957
- // multipart/mixed with the body + one base64 attachment part per file.
958
- // Each attachment chunk is wrapped at 76-char lines per RFC 2045.
959
- const boundary = `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
995
+ // multipart/mixed wrapping (multipart/alternative | text/plain)
996
+ // + one base64 attachment part per file. Attachment chunks are
997
+ // wrapped at 76-char lines per RFC 2045.
998
+ const mixedBoundary = newBoundary();
960
999
  const wrap76 = (s) => s.replace(/.{1,76}/g, m => m).match(/.{1,76}/g)?.join("\r\n") || s;
1000
+ const inner = makeInner();
961
1001
  const parts = [];
962
- parts.push(`--${boundary}\r\n` +
963
- `Content-Type: text/html; charset=UTF-8\r\n` +
964
- `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
965
- `${bodyEncoded}\r\n`);
1002
+ parts.push(`--${mixedBoundary}\r\n` +
1003
+ inner.headers.join("\r\n") +
1004
+ `\r\n\r\n` +
1005
+ inner.body);
966
1006
  for (const att of msg.attachments) {
967
1007
  const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
968
1008
  const mime = att.mimeType || "application/octet-stream";
969
1009
  const wrapped = wrap76(att.dataBase64 || "");
970
- parts.push(`--${boundary}\r\n` +
1010
+ parts.push(`--${mixedBoundary}\r\n` +
971
1011
  `Content-Type: ${mime}; name="${filename}"\r\n` +
972
1012
  `Content-Disposition: attachment; filename="${filename}"\r\n` +
973
1013
  `Content-Transfer-Encoding: base64\r\n\r\n` +
@@ -975,21 +1015,37 @@ export class MailxService {
975
1015
  }
976
1016
  const headers = [
977
1017
  ...commonHeaders,
978
- `Content-Type: multipart/mixed; boundary="${boundary}"`,
1018
+ `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`,
979
1019
  ].join("\r\n");
980
- rawMessage = `${headers}\r\n\r\n${parts.join("")}--${boundary}--\r\n`;
1020
+ rawMessage = `${headers}\r\n\r\n${parts.join("")}--${mixedBoundary}--\r\n`;
981
1021
  }
982
1022
  else {
1023
+ const inner = makeInner();
983
1024
  const headers = [
984
1025
  ...commonHeaders,
985
- `Content-Type: text/html; charset=UTF-8`,
986
- `Content-Transfer-Encoding: quoted-printable`,
1026
+ ...inner.headers,
987
1027
  ].join("\r\n");
988
- rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
1028
+ rawMessage = `${headers}\r\n\r\n${inner.body}`;
989
1029
  }
990
1030
  lap(`MIME assembled (${rawMessage.length} bytes${hasAttachments ? `, ${msg.attachments.length} attachment(s)` : ""})`);
991
1031
  this.imapManager.queueOutgoingLocal(account.id, rawMessage);
992
1032
  lap("queued to disk");
1033
+ // Local-first Sent: don't wait for SMTP+APPEND+sync — put a pink row
1034
+ // into the local Sent folder right now so the user sees their letter
1035
+ // the instant they click Send. upsertMessage's Message-ID rebind
1036
+ // picks up the real APPENDUID later (same row, different UID).
1037
+ // Fire-and-forget: failure here must not hold up the send ACK.
1038
+ this.imapManager.insertOptimisticSentRow(account.id, {
1039
+ messageId,
1040
+ inReplyTo: msg.inReplyTo || "",
1041
+ references: msg.references || [],
1042
+ subject: msg.subject || "",
1043
+ from: { name: account.name, address: fromAddr },
1044
+ to: msg.to || [],
1045
+ cc: msg.cc || [],
1046
+ bcc: msg.bcc || [],
1047
+ date: Date.now(),
1048
+ }, rawMessage).catch(() => { });
993
1049
  console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
994
1050
  // Contacts recording is off the critical path — deferred until after
995
1051
  // the IPC ACK so a slow DB write can't stall the send.
@@ -835,10 +835,15 @@ export class MailxDB {
835
835
  // LEFT JOIN sync_actions so each row carries a `pending` flag —
836
836
  // true when the user has a queued local action (move/flag/delete)
837
837
  // not yet acknowledged by the server. UI renders these in pink so
838
- // local-only state is visible (Slice C of S1).
839
- const rows = this.db.prepare(`SELECT m.*, EXISTS(
840
- SELECT 1 FROM sync_actions sa
841
- WHERE sa.account_id = m.account_id AND sa.uid = m.uid
838
+ // local-only state is visible (Slice C of S1). Negative UIDs also
839
+ // count as pending: that's the convention for optimistic local
840
+ // inserts (e.g. Sent rows written the moment the user hits Send,
841
+ // before the real APPENDUID comes back from the server).
842
+ const rows = this.db.prepare(`SELECT m.*, (
843
+ EXISTS(
844
+ SELECT 1 FROM sync_actions sa
845
+ WHERE sa.account_id = m.account_id AND sa.uid = m.uid
846
+ ) OR m.uid < 0
842
847
  ) AS pending
843
848
  FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
844
849
  ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
@@ -9,7 +9,7 @@
9
9
  * - Settings via IndexedDB + GDrive API instead of filesystem
10
10
  * - No dns.resolveMx — provider detection is static (Gmail/Outlook/Yahoo/iCloud)
11
11
  */
12
- import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
12
+ import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
13
13
  import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo } from "./web-settings.js";
14
14
  /** Parse an RFC 2822 message from raw bytes. Handles basic MIME. */
15
15
  function parseEmailSource(raw) {
@@ -349,20 +349,51 @@ export class WebMailxService {
349
349
  const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
350
350
  const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
351
351
  const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
352
- const body = msg.bodyHtml || msg.bodyText || "";
353
- const bodyEncoded = encodeQuotedPrintable(body);
352
+ // HTML-bodied send gets a multipart/alternative wrapper with a
353
+ // text/plain derived from the HTML — matches desktop's path; spam
354
+ // filters score HTML-only mail harshly without it.
355
+ const hasHtml = !!msg.bodyHtml;
356
+ const htmlBody = msg.bodyHtml || "";
357
+ const textBody = msg.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
358
+ const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
359
+ const textEncoded = encodeQuotedPrintable(textBody);
354
360
  const domain = account.email.split("@")[1] || "mailx.local";
355
361
  const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
356
- const headers = [
362
+ const envelope = [
357
363
  `From: ${fromHeader}`, `To: ${to}`,
358
364
  cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
359
365
  `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
360
366
  `Message-ID: ${messageId}`,
361
367
  msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
362
368
  msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
363
- `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
364
- ].filter(h => h !== null).join("\r\n");
365
- const rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
369
+ `MIME-Version: 1.0`,
370
+ ].filter(h => h !== null);
371
+ let rawMessage;
372
+ if (hasHtml) {
373
+ const altBoundary = `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
374
+ const body = `--${altBoundary}\r\n` +
375
+ `Content-Type: text/plain; charset=UTF-8\r\n` +
376
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
377
+ `${textEncoded}\r\n` +
378
+ `--${altBoundary}\r\n` +
379
+ `Content-Type: text/html; charset=UTF-8\r\n` +
380
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
381
+ `${htmlEncoded}\r\n` +
382
+ `--${altBoundary}--\r\n`;
383
+ const headers = [
384
+ ...envelope,
385
+ `Content-Type: multipart/alternative; boundary="${altBoundary}"`,
386
+ ].join("\r\n");
387
+ rawMessage = `${headers}\r\n\r\n${body}`;
388
+ }
389
+ else {
390
+ const headers = [
391
+ ...envelope,
392
+ `Content-Type: text/plain; charset=UTF-8`,
393
+ `Content-Transfer-Encoding: quoted-printable`,
394
+ ].join("\r\n");
395
+ rawMessage = `${headers}\r\n\r\n${textEncoded}`;
396
+ }
366
397
  this.syncManager.queueOutgoingLocal(account.id, rawMessage);
367
398
  for (const addr of msg.to)
368
399
  this.db.recordSentAddress(addr.name, addr.address);
@@ -277,6 +277,19 @@ export declare function sanitizeHtml(html: string): {
277
277
  };
278
278
  /** Encode text as RFC 2045 quoted-printable. */
279
279
  export declare function encodeQuotedPrintable(text: string): string;
280
+ /** Render an HTML document as a plain-text approximation suitable for the
281
+ * text/plain alternative part of a multipart/alternative outgoing MIME
282
+ * message. Not a full HTML-to-text engine — just enough to give non-HTML
283
+ * clients (plain-text readers, spam filters scoring on text/plain, people
284
+ * who turned HTML off) a readable fallback. Preserves line breaks for
285
+ * `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the
286
+ * common HTML entities, and collapses runs of whitespace.
287
+ *
288
+ * Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;
289
+ * shipping a real text part typically drops the score by 1–2 points. Also
290
+ * matches the behaviour of every other mainstream mail client — sending a
291
+ * text/html part alone marks mailx as an outlier in mail logs. */
292
+ export declare function htmlToPlainText(html: string): string;
280
293
  /** Parse search query into structured conditions.
281
294
  * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
282
295
  * is:flagged, is:unread, is:read. Unqualified terms search across subject /
@@ -65,6 +65,59 @@ export function encodeQuotedPrintable(text) {
65
65
  result += line;
66
66
  return result;
67
67
  }
68
+ /** Render an HTML document as a plain-text approximation suitable for the
69
+ * text/plain alternative part of a multipart/alternative outgoing MIME
70
+ * message. Not a full HTML-to-text engine — just enough to give non-HTML
71
+ * clients (plain-text readers, spam filters scoring on text/plain, people
72
+ * who turned HTML off) a readable fallback. Preserves line breaks for
73
+ * `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the
74
+ * common HTML entities, and collapses runs of whitespace.
75
+ *
76
+ * Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;
77
+ * shipping a real text part typically drops the score by 1–2 points. Also
78
+ * matches the behaviour of every other mainstream mail client — sending a
79
+ * text/html part alone marks mailx as an outlier in mail logs. */
80
+ export function htmlToPlainText(html) {
81
+ if (!html)
82
+ return "";
83
+ let s = html;
84
+ // Drop <style> / <script> entirely (their contents aren't readable text).
85
+ s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
86
+ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
87
+ // Block-level breaks — treat closing tags as line terminators so
88
+ // paragraphs don't run together.
89
+ s = s.replace(/<br\s*\/?\s*>/gi, "\n");
90
+ s = s.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)\s*>/gi, "\n");
91
+ // List-item leading bullet (rough but readable).
92
+ s = s.replace(/<li\b[^>]*>/gi, " • ");
93
+ // Anchor: keep href in parens after the text so URLs survive.
94
+ s = s.replace(/<a\b[^>]*href\s*=\s*(['"])([^'"]*)\1[^>]*>([\s\S]*?)<\/a>/gi, (_m, _q, href, text) => {
95
+ const t = text.replace(/<[^>]+>/g, "").trim();
96
+ return t && t !== href ? `${t} (${href})` : href;
97
+ });
98
+ // Strip remaining tags.
99
+ s = s.replace(/<[^>]+>/g, "");
100
+ // Decode a pragmatic set of HTML entities — the rare ones survive as-is.
101
+ s = s.replace(/&nbsp;/gi, " ")
102
+ .replace(/&amp;/gi, "&")
103
+ .replace(/&lt;/gi, "<")
104
+ .replace(/&gt;/gi, ">")
105
+ .replace(/&quot;/gi, "\"")
106
+ .replace(/&#39;/gi, "'")
107
+ .replace(/&apos;/gi, "'")
108
+ .replace(/&mdash;/gi, "—")
109
+ .replace(/&ndash;/gi, "–")
110
+ .replace(/&hellip;/gi, "…")
111
+ .replace(/&#(\d+);/g, (_m, n) => String.fromCodePoint(parseInt(n, 10)))
112
+ .replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)));
113
+ // Normalise whitespace: collapse runs of spaces/tabs, trim per-line,
114
+ // cap consecutive blank lines at 2.
115
+ s = s.replace(/[ \t]+/g, " ")
116
+ .split("\n").map(l => l.replace(/^[ \t]+|[ \t]+$/g, "")).join("\n")
117
+ .replace(/\n{3,}/g, "\n\n")
118
+ .trim();
119
+ return s;
120
+ }
68
121
  /** Parse search query into structured conditions.
69
122
  * Supports qualifiers: from:, to:, subject:, date:, has:attachment,
70
123
  * is:flagged, is:unread, is:read. Unqualified terms search across subject /