@bobfrankston/mailx 1.0.406 → 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.
- package/client/android.html +0 -10
- package/client/app.js +24 -1
- package/client/components/calendar-sidebar.js +38 -1
- package/client/components/folder-tree.js +44 -0
- package/client/components/message-list.js +7 -102
- package/client/index.html +5 -10
- package/client/lib/mailxapi.js +34 -0
- package/client/styles/components.css +18 -33
- package/package.json +13 -2
- package/packages/mailx-host/package.json +1 -1
- package/packages/mailx-imap/index.d.ts +26 -1
- package/packages/mailx-imap/index.js +58 -0
- package/packages/mailx-service/index.js +72 -16
- package/packages/mailx-store/db.js +9 -4
- package/packages/mailx-store-web/web-service.js +38 -7
- package/packages/mailx-types/index.d.ts +13 -0
- package/packages/mailx-types/index.js +53 -0
package/client/android.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
|
|
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
|
-
|
|
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>
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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
|
|
1111
|
-
|
|
1112
|
-
|
|
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.
|
|
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": {
|
|
@@ -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
|
-
|
|
941
|
-
|
|
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
|
|
958
|
-
//
|
|
959
|
-
|
|
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(`--${
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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(`--${
|
|
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="${
|
|
1018
|
+
`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`,
|
|
979
1019
|
].join("\r\n");
|
|
980
|
-
rawMessage = `${headers}\r\n\r\n${parts.join("")}--${
|
|
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
|
-
|
|
986
|
-
`Content-Transfer-Encoding: quoted-printable`,
|
|
1026
|
+
...inner.headers,
|
|
987
1027
|
].join("\r\n");
|
|
988
|
-
rawMessage = `${headers}\r\n\r\n${
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
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`,
|
|
364
|
-
].filter(h => h !== null)
|
|
365
|
-
|
|
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(/ /gi, " ")
|
|
102
|
+
.replace(/&/gi, "&")
|
|
103
|
+
.replace(/</gi, "<")
|
|
104
|
+
.replace(/>/gi, ">")
|
|
105
|
+
.replace(/"/gi, "\"")
|
|
106
|
+
.replace(/'/gi, "'")
|
|
107
|
+
.replace(/'/gi, "'")
|
|
108
|
+
.replace(/—/gi, "—")
|
|
109
|
+
.replace(/–/gi, "–")
|
|
110
|
+
.replace(/…/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 /
|