@bobfrankston/mailx 1.0.447 → 1.0.449
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/app.js +40 -7
- package/client/components/message-viewer.js +37 -3
- package/client/index.html +3 -0
- package/client/lib/message-state.js +35 -5
- package/package.json +1 -1
package/client/app.js
CHANGED
|
@@ -799,19 +799,24 @@ function showComposeOverlay(title = "Compose") {
|
|
|
799
799
|
wrapper.appendChild(frame);
|
|
800
800
|
document.body.appendChild(wrapper);
|
|
801
801
|
}
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
802
|
+
// Marketing-email layout tables (deeply nested, fixed widths) collapse to
|
|
803
|
+
// 30-40px columns inside a phone-width compose pane and wrap text
|
|
804
|
+
// character-by-character. Strip styles + flatten tables before quoting.
|
|
805
|
+
function sanitizeQuotedBody(msg) {
|
|
806
806
|
let body = msg.bodyHtml || `<pre>${msg.bodyText || ""}</pre>`;
|
|
807
|
-
// Strip style tags and attributes
|
|
808
807
|
body = body.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
|
|
809
808
|
body = body.replace(/\s+style="[^"]*"/gi, "");
|
|
810
809
|
body = body.replace(/\s+class="[^"]*"/gi, "");
|
|
811
|
-
|
|
810
|
+
body = body.replace(/\s+(width|height|align|valign|bgcolor|cellpadding|cellspacing|border)="[^"]*"/gi, "");
|
|
812
811
|
body = body.replace(/<table[^>]*>/gi, "<div>").replace(/<\/table>/gi, "</div>");
|
|
813
812
|
body = body.replace(/<t[rdh][^>]*>/gi, "").replace(/<\/t[rdh]>/gi, " ");
|
|
814
813
|
body = body.replace(/<thead[^>]*>|<\/thead>|<tbody[^>]*>|<\/tbody>/gi, "");
|
|
814
|
+
return body;
|
|
815
|
+
}
|
|
816
|
+
function quoteBody(msg) {
|
|
817
|
+
const date = new Date(msg.date).toLocaleString();
|
|
818
|
+
const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;
|
|
819
|
+
const body = sanitizeQuotedBody(msg);
|
|
815
820
|
// Two blank lines above the quote so the cursor lands with breathing room
|
|
816
821
|
// between the user's reply and the "On ... wrote:" line.
|
|
817
822
|
return `<br><br><div class="reply"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;
|
|
@@ -820,7 +825,7 @@ function forwardBody(msg) {
|
|
|
820
825
|
const date = new Date(msg.date).toLocaleString();
|
|
821
826
|
const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;
|
|
822
827
|
const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
823
|
-
const body = msg
|
|
828
|
+
const body = sanitizeQuotedBody(msg);
|
|
824
829
|
return `<br><br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
|
|
825
830
|
}
|
|
826
831
|
let lastDeleted = null;
|
|
@@ -2905,6 +2910,34 @@ optCheckReputation?.addEventListener("change", () => {
|
|
|
2905
2910
|
saveSettings(settings);
|
|
2906
2911
|
}).catch(() => { });
|
|
2907
2912
|
});
|
|
2913
|
+
// Auto mark-as-read settings (per-device localStorage; the viewer reads
|
|
2914
|
+
// these directly when showing a message). Default on with a 2s delay so
|
|
2915
|
+
// scrolling through a folder doesn't mark every glanced-at message as
|
|
2916
|
+
// read, but a deliberate read still gets recorded.
|
|
2917
|
+
const optAutomarkRead = document.getElementById("opt-automark-read");
|
|
2918
|
+
const optAutomarkDelay = document.getElementById("opt-automark-delay");
|
|
2919
|
+
try {
|
|
2920
|
+
if (optAutomarkRead)
|
|
2921
|
+
optAutomarkRead.checked = localStorage.getItem("mailx-automark-read") !== "false";
|
|
2922
|
+
if (optAutomarkDelay)
|
|
2923
|
+
optAutomarkDelay.value = localStorage.getItem("mailx-automark-delay") || "2";
|
|
2924
|
+
}
|
|
2925
|
+
catch { /* private mode */ }
|
|
2926
|
+
optAutomarkRead?.addEventListener("change", () => {
|
|
2927
|
+
try {
|
|
2928
|
+
localStorage.setItem("mailx-automark-read", String(optAutomarkRead.checked));
|
|
2929
|
+
}
|
|
2930
|
+
catch { /* */ }
|
|
2931
|
+
});
|
|
2932
|
+
optAutomarkDelay?.addEventListener("change", () => {
|
|
2933
|
+
const v = parseFloat(optAutomarkDelay.value);
|
|
2934
|
+
if (Number.isFinite(v) && v >= 0) {
|
|
2935
|
+
try {
|
|
2936
|
+
localStorage.setItem("mailx-automark-delay", String(v));
|
|
2937
|
+
}
|
|
2938
|
+
catch { /* */ }
|
|
2939
|
+
}
|
|
2940
|
+
});
|
|
2908
2941
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
2909
2942
|
// Wait for server ready signal, then fetch version
|
|
2910
2943
|
const versionPromise = getVersion();
|
|
@@ -297,10 +297,44 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
297
297
|
return;
|
|
298
298
|
currentMessage = msg;
|
|
299
299
|
currentAccountId = accountId;
|
|
300
|
-
// Mark as read
|
|
300
|
+
// Mark as read — gated by user prefs:
|
|
301
|
+
// - mailx-automark-read (default "true"): if "false", never auto-mark
|
|
302
|
+
// - mailx-automark-delay (default "2"): seconds to wait before
|
|
303
|
+
// marking. Lets the user click through messages quickly without
|
|
304
|
+
// marking ones they didn't actually read. The timer is tied to
|
|
305
|
+
// showMessageGeneration; navigating to another message advances
|
|
306
|
+
// the generation and cancels the pending mark.
|
|
301
307
|
if (!msg.flags.includes("\\Seen")) {
|
|
302
|
-
|
|
303
|
-
|
|
308
|
+
let enabled = true;
|
|
309
|
+
let delaySec = 2;
|
|
310
|
+
try {
|
|
311
|
+
enabled = localStorage.getItem("mailx-automark-read") !== "false";
|
|
312
|
+
const d = parseFloat(localStorage.getItem("mailx-automark-delay") || "2");
|
|
313
|
+
if (Number.isFinite(d) && d >= 0)
|
|
314
|
+
delaySec = d;
|
|
315
|
+
}
|
|
316
|
+
catch { /* private mode — defaults */ }
|
|
317
|
+
if (enabled) {
|
|
318
|
+
const captureGen = gen;
|
|
319
|
+
const newFlags = [...msg.flags, "\\Seen"];
|
|
320
|
+
if (delaySec === 0) {
|
|
321
|
+
updateFlags(accountId, uid, newFlags);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
setTimeout(() => {
|
|
325
|
+
// Stale: user moved on before the timer fired.
|
|
326
|
+
if (captureGen !== showMessageGeneration)
|
|
327
|
+
return;
|
|
328
|
+
updateFlags(accountId, uid, newFlags);
|
|
329
|
+
// Reflect locally so the list row stops looking unread.
|
|
330
|
+
msg.flags = newFlags;
|
|
331
|
+
try {
|
|
332
|
+
state.updateMessageFlags(accountId, uid, newFlags);
|
|
333
|
+
}
|
|
334
|
+
catch { /* */ }
|
|
335
|
+
}, delaySec * 1000);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
304
338
|
}
|
|
305
339
|
// Header
|
|
306
340
|
headerEl.hidden = false;
|
package/client/index.html
CHANGED
|
@@ -57,6 +57,9 @@
|
|
|
57
57
|
<label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
|
|
58
58
|
<label class="tb-menu-item" title="When opening a message with remote content, look up the sender's domain in three free DNS blocklists in parallel: Spamhaus DBL, SURBL, URIBL. The banner shows N-of-3 services flagging the domain. Each query leaks the bare domain to that DNSBL's DNS. Off by default."><input type="checkbox" id="opt-check-reputation"> Check sender reputation (DNSBLs)</label>
|
|
59
59
|
<hr class="tb-menu-sep">
|
|
60
|
+
<label class="tb-menu-item" title="When viewing a message, automatically mark it as read after a short delay. Uncheck to require an explicit Mark-Read action."><input type="checkbox" id="opt-automark-read" checked> Auto mark-as-read</label>
|
|
61
|
+
<label class="tb-menu-item" title="Seconds to wait before auto-marking a viewed message as read. Higher = scroll past messages without changing their unread state. 0 = mark immediately.">Mark-read delay (sec) <input type="number" id="opt-automark-delay" min="0" max="30" step="0.5" style="width:4em"></label>
|
|
62
|
+
<hr class="tb-menu-sep">
|
|
60
63
|
<button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
|
|
61
64
|
<button class="tb-menu-item" id="btn-open-mailx-dir" title="Open ~/.mailx in file explorer">Open mailx folder...</button>
|
|
62
65
|
<button class="tb-menu-item" id="btn-open-log" title="Open today's log file">Open log...</button>
|
|
@@ -27,13 +27,18 @@ function notify(change) {
|
|
|
27
27
|
catch { /* don't let one subscriber break others */ }
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
/** Replace the entire message list (folder load, search, unified inbox)
|
|
30
|
+
/** Replace the entire message list (folder load, search, unified inbox).
|
|
31
|
+
* Selection stays even when the previously-selected message isn't in
|
|
32
|
+
* the new list — that case happens whenever a periodic sync re-fetches
|
|
33
|
+
* page 1 of the unified inbox and a row the user opened earlier has
|
|
34
|
+
* scrolled off, or when the same row's accountId/uid pair changes
|
|
35
|
+
* shape after dedup. Yanking the viewer mid-read was a user-reported
|
|
36
|
+
* surprise (2026-04-30: "I was reading a message but now it just
|
|
37
|
+
* jumped to Select a message to read"). The viewer keeps the message
|
|
38
|
+
* it has; it'll only clear when the user explicitly clicks elsewhere
|
|
39
|
+
* or the message is removed via removeMessages (real delete/move). */
|
|
31
40
|
export function setMessages(msgs) {
|
|
32
41
|
messages = msgs;
|
|
33
|
-
// If the previously selected message is no longer in the list, deselect
|
|
34
|
-
if (selected && !messages.find(m => m.uid === selected.uid && m.accountId === selected.accountId)) {
|
|
35
|
-
selected = null;
|
|
36
|
-
}
|
|
37
42
|
notify("messages");
|
|
38
43
|
}
|
|
39
44
|
/** Get current messages */
|
|
@@ -52,6 +57,10 @@ export function getSelected() {
|
|
|
52
57
|
/**
|
|
53
58
|
* Remove messages from the list (after move or delete).
|
|
54
59
|
* If the selected message is removed, auto-selects the next message or null.
|
|
60
|
+
* Decrements dupeCount on any remaining messages that shared a Message-ID
|
|
61
|
+
* with one of the removed rows — so the unified-inbox ⇆ marker disappears
|
|
62
|
+
* when one half of a duplicate pair is deleted, instead of pointing at a
|
|
63
|
+
* sibling that no longer exists.
|
|
55
64
|
*/
|
|
56
65
|
export function removeMessages(uids) {
|
|
57
66
|
const removeSet = new Set(uids.map(u => `${u.accountId}:${u.uid}`));
|
|
@@ -60,7 +69,28 @@ export function removeMessages(uids) {
|
|
|
60
69
|
selectedIdx = messages.findIndex(m => m.uid === selected.uid && m.accountId === selected.accountId);
|
|
61
70
|
}
|
|
62
71
|
const wasSelectedRemoved = selected && removeSet.has(`${selected.accountId}:${selected.uid}`);
|
|
72
|
+
// Capture Message-IDs of the rows about to leave, so we can fix up the
|
|
73
|
+
// dupeCount on any remaining siblings. Only IDs that were in the list
|
|
74
|
+
// and had a non-empty messageId count — empty IDs would match every
|
|
75
|
+
// headerless row and falsely "merge" them.
|
|
76
|
+
const removedIds = new Set();
|
|
77
|
+
for (const m of messages) {
|
|
78
|
+
if (removeSet.has(`${m.accountId}:${m.uid}`) && m.messageId) {
|
|
79
|
+
removedIds.add(m.messageId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
63
82
|
messages = messages.filter(m => !removeSet.has(`${m.accountId}:${m.uid}`));
|
|
83
|
+
// Sibling dupeCount adjustment. dupeCount is server-recomputed on the
|
|
84
|
+
// next getUnifiedInbox fetch, but we don't want to wait for that —
|
|
85
|
+
// user just deleted, the marker should drop now. Decrement once per
|
|
86
|
+
// remaining message whose messageId matches a removed one.
|
|
87
|
+
if (removedIds.size > 0) {
|
|
88
|
+
for (const m of messages) {
|
|
89
|
+
if (m.messageId && removedIds.has(m.messageId) && typeof m.dupeCount === "number") {
|
|
90
|
+
m.dupeCount = Math.max(0, m.dupeCount - 1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
64
94
|
if (wasSelectedRemoved) {
|
|
65
95
|
// Auto-select next message (same index, or last, or null)
|
|
66
96
|
if (messages.length > 0) {
|