@bobfrankston/mailx 1.0.306 → 1.0.313
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/bin/mailx.js +56 -1
- package/client/app.js +436 -21
- package/client/components/folder-picker.js +119 -0
- package/client/components/message-list.js +23 -1
- package/client/components/message-viewer.js +140 -22
- package/client/compose/compose.css +50 -0
- package/client/compose/compose.js +68 -12
- package/client/compose/editor.js +51 -7
- package/client/index.html +19 -0
- package/client/lib/api-client.js +12 -0
- package/client/lib/mailxapi.js +16 -7
- package/client/styles/components.css +115 -0
- package/client/styles/layout.css +64 -14
- package/client/styles/variables.css +1 -0
- package/package.json +8 -5
- package/packages/mailx-core/index.d.ts +3 -0
- package/packages/mailx-core/index.js +45 -7
- package/packages/mailx-host/index.d.ts +21 -0
- package/packages/mailx-host/index.js +31 -0
- package/packages/mailx-host/package.json +23 -0
- package/packages/mailx-imap/index.js +33 -0
- package/packages/mailx-service/index.d.ts +18 -1
- package/packages/mailx-service/index.js +198 -6
- package/packages/mailx-service/jsonrpc.js +8 -0
- package/packages/mailx-store/db.js +47 -5
- package/packages/mailx-store-web/android-bootstrap.js +91 -2
- package/packages/mailx-store-web/db.js +4 -1
- package/packages/mailx-store-web/main-thread-host.js +2 -2
- package/packages/mailx-types/index.d.ts +21 -0
- package/tempfix.cmd +77 -0
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Message list component — renders paginated message rows.
|
|
3
3
|
* Reads from message-state; operations mutate state, list reacts.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags, getThreadMessages } from "../lib/api-client.js";
|
|
5
|
+
import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags, getThreadMessages, moveMessages as apiMoveMessages } from "../lib/api-client.js";
|
|
6
6
|
import * as state from "../lib/message-state.js";
|
|
7
7
|
import { showContextMenu } from "./context-menu.js";
|
|
8
|
+
import { pickFolder } from "./folder-picker.js";
|
|
8
9
|
let onMessageSelect;
|
|
9
10
|
let currentAccountId;
|
|
10
11
|
let currentFolderId;
|
|
@@ -620,6 +621,27 @@ function appendMessages(body, accountId, items) {
|
|
|
620
621
|
action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })),
|
|
621
622
|
},
|
|
622
623
|
{ label: "", action: () => { }, separator: true },
|
|
624
|
+
{
|
|
625
|
+
label: "Move to folder…",
|
|
626
|
+
action: async () => {
|
|
627
|
+
// Move all currently-selected rows (or just this one if it's the only selection)
|
|
628
|
+
const selectedRows = Array.from(document.querySelectorAll(".ml-row.selected"));
|
|
629
|
+
const uids = selectedRows.length > 0
|
|
630
|
+
? selectedRows.map((r) => Number(r.dataset.uid)).filter(u => !isNaN(u))
|
|
631
|
+
: [msg.uid];
|
|
632
|
+
const pick = await pickFolder(msgAccountId, { excludeFolderIds: [msg.folderId] });
|
|
633
|
+
if (!pick)
|
|
634
|
+
return;
|
|
635
|
+
try {
|
|
636
|
+
await apiMoveMessages(msgAccountId, uids, pick.folderId);
|
|
637
|
+
// Remove from local state — reconciler handles server sync.
|
|
638
|
+
state.removeMessages(uids.map(u => ({ accountId: msgAccountId, uid: u })));
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
alert(`Move failed: ${err.message}`);
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
},
|
|
623
645
|
{
|
|
624
646
|
label: "Delete",
|
|
625
647
|
action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
|
|
@@ -64,6 +64,61 @@ function setZoom(z, doc) {
|
|
|
64
64
|
}
|
|
65
65
|
/** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,
|
|
66
66
|
* keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */
|
|
67
|
+
/** Run AI translate on `text` and show result in a small modal. Disabled
|
|
68
|
+
* by default — user enables via Settings (translateEnabled in
|
|
69
|
+
* AutocompleteSettings). When disabled, the modal explains how to enable. */
|
|
70
|
+
async function translateAndShow(text) {
|
|
71
|
+
if (!text.trim())
|
|
72
|
+
return;
|
|
73
|
+
const status = document.getElementById("status-sync");
|
|
74
|
+
if (status)
|
|
75
|
+
status.textContent = "Translating…";
|
|
76
|
+
const overlay = document.createElement("div");
|
|
77
|
+
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;";
|
|
78
|
+
const modal = document.createElement("div");
|
|
79
|
+
modal.style.cssText = "background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;width:560px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 4px 24px rgba(0,0,0,0.3);";
|
|
80
|
+
const header = document.createElement("div");
|
|
81
|
+
header.style.cssText = "padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;display:flex;justify-content:space-between;align-items:center;";
|
|
82
|
+
header.innerHTML = `<span>Translation</span><button style="cursor:pointer;border:0;background:transparent;font-size:16px;" aria-label="Close">×</button>`;
|
|
83
|
+
const body = document.createElement("div");
|
|
84
|
+
body.style.cssText = "flex:1;overflow:auto;padding:12px 14px;white-space:pre-wrap;font-size:13px;line-height:1.45;";
|
|
85
|
+
body.textContent = "Working…";
|
|
86
|
+
modal.appendChild(header);
|
|
87
|
+
modal.appendChild(body);
|
|
88
|
+
overlay.appendChild(modal);
|
|
89
|
+
document.body.appendChild(overlay);
|
|
90
|
+
const close = () => overlay.remove();
|
|
91
|
+
header.querySelector("button")?.addEventListener("click", close);
|
|
92
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay)
|
|
93
|
+
close(); });
|
|
94
|
+
document.addEventListener("keydown", function onKey(e) {
|
|
95
|
+
if (e.key === "Escape") {
|
|
96
|
+
document.removeEventListener("keydown", onKey);
|
|
97
|
+
close();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
const { aiTransform } = await import("../lib/api-client.js");
|
|
102
|
+
const r = await aiTransform({ action: "translate", text, targetLang: "en" });
|
|
103
|
+
if (r.text) {
|
|
104
|
+
body.textContent = r.text;
|
|
105
|
+
if (status)
|
|
106
|
+
status.textContent = "";
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
body.innerHTML = `<div style="color:var(--muted, #888);">No result.</div>` +
|
|
110
|
+
`<div style="margin-top:8px;font-size:12px;color:var(--muted, #888);">${r.reason || ""}</div>` +
|
|
111
|
+
`<div style="margin-top:14px;font-size:12px;">Enable AI translate in Settings → AI features (off by default).</div>`;
|
|
112
|
+
if (status)
|
|
113
|
+
status.textContent = `Translate: ${r.reason || "no result"}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
body.textContent = `Error: ${err?.message || String(err)}`;
|
|
118
|
+
if (status)
|
|
119
|
+
status.textContent = `Translate error: ${err?.message || ""}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
67
122
|
function installPreviewControls(iframe) {
|
|
68
123
|
const attach = () => {
|
|
69
124
|
const doc = iframe.contentDocument;
|
|
@@ -124,18 +179,23 @@ function installPreviewControls(iframe) {
|
|
|
124
179
|
const x = rect.left + me.clientX;
|
|
125
180
|
const y = rect.top + me.clientY;
|
|
126
181
|
const pct = Math.round(previewZoom * 100);
|
|
182
|
+
const sel = doc.defaultView?.getSelection();
|
|
183
|
+
const selectedText = sel?.toString().trim() || "";
|
|
127
184
|
const items = [
|
|
128
185
|
{ label: "Copy", action: () => doc.execCommand("copy") },
|
|
129
186
|
{ label: "Select all", action: () => {
|
|
130
|
-
const
|
|
131
|
-
if (!
|
|
187
|
+
const s = doc.defaultView?.getSelection();
|
|
188
|
+
if (!s)
|
|
132
189
|
return;
|
|
133
190
|
const range = doc.createRange();
|
|
134
191
|
range.selectNodeContents(doc.body);
|
|
135
|
-
|
|
136
|
-
|
|
192
|
+
s.removeAllRanges();
|
|
193
|
+
s.addRange(range);
|
|
137
194
|
} },
|
|
138
195
|
{ label: "", action: () => { }, separator: true },
|
|
196
|
+
{ label: selectedText ? "Translate selection" : "Translate message",
|
|
197
|
+
action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) },
|
|
198
|
+
{ label: "", action: () => { }, separator: true },
|
|
139
199
|
{ label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
|
|
140
200
|
{ label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
|
|
141
201
|
{ label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
|
|
@@ -243,23 +303,55 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
243
303
|
}
|
|
244
304
|
headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
|
245
305
|
// Unsubscribe button (upper right of header).
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
306
|
+
// Priority:
|
|
307
|
+
// 1. RFC 8058 one-click (HTTPS + List-Unsubscribe-Post header) — POST server-side
|
|
308
|
+
// 2. HTTPS URL — open in a new tab (two-click flow, usually a confirmation page)
|
|
309
|
+
// 3. mailto: URL — open a pre-filled compose window (so the unsubscribe
|
|
310
|
+
// reply gets sent from the correct mailx account, not the OS default
|
|
311
|
+
// mail handler)
|
|
250
312
|
const unsubBtn = document.getElementById("mv-unsubscribe");
|
|
251
|
-
const
|
|
313
|
+
const httpUrl = msg.listUnsubscribeHttp || "";
|
|
314
|
+
const mailUrl = msg.listUnsubscribeMail || "";
|
|
315
|
+
const oneClick = !!msg.listUnsubscribeOneClick;
|
|
316
|
+
const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || "";
|
|
252
317
|
if (unsubBtn) {
|
|
253
|
-
if (
|
|
318
|
+
if (anyUrl) {
|
|
254
319
|
unsubBtn.hidden = false;
|
|
255
|
-
unsubBtn.textContent = "Unsubscribe";
|
|
256
|
-
unsubBtn.title =
|
|
320
|
+
unsubBtn.textContent = oneClick && httpUrl ? "Unsubscribe (1-click)" : "Unsubscribe";
|
|
321
|
+
unsubBtn.title = anyUrl;
|
|
257
322
|
unsubBtn.href = "#";
|
|
258
|
-
unsubBtn.onclick = (e) => {
|
|
323
|
+
unsubBtn.onclick = async (e) => {
|
|
259
324
|
e.preventDefault();
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
325
|
+
const status = document.getElementById("status-sync");
|
|
326
|
+
if (oneClick && httpUrl) {
|
|
327
|
+
unsubBtn.textContent = "Unsubscribing…";
|
|
328
|
+
try {
|
|
329
|
+
const { unsubscribeOneClick } = await import("../lib/api-client.js");
|
|
330
|
+
const r = await unsubscribeOneClick(httpUrl);
|
|
331
|
+
if (r?.ok) {
|
|
332
|
+
unsubBtn.textContent = "Unsubscribed";
|
|
333
|
+
if (status)
|
|
334
|
+
status.textContent = `Unsubscribed (HTTP ${r.status})`;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
unsubBtn.textContent = `Failed: HTTP ${r?.status ?? "?"}`;
|
|
338
|
+
if (status)
|
|
339
|
+
status.textContent = `Unsubscribe failed: ${r?.status} ${r?.statusText || ""}`.trim();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
unsubBtn.textContent = "Unsubscribe failed";
|
|
344
|
+
if (status)
|
|
345
|
+
status.textContent = `Unsubscribe error: ${err.message}`;
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (httpUrl) {
|
|
350
|
+
window.open(httpUrl, "_blank");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (mailUrl) {
|
|
354
|
+
const m = mailUrl.match(/^mailto:([^?]*)(?:\?(.*))?$/i);
|
|
263
355
|
const to = m?.[1] ? decodeURIComponent(m[1]) : "";
|
|
264
356
|
const qs = new URLSearchParams(m?.[2] || "");
|
|
265
357
|
const subject = qs.get("subject") || "Unsubscribe";
|
|
@@ -278,9 +370,6 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
278
370
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
279
371
|
document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "new" } }));
|
|
280
372
|
}
|
|
281
|
-
else {
|
|
282
|
-
window.open(unsubUrl, "_blank");
|
|
283
|
-
}
|
|
284
373
|
};
|
|
285
374
|
}
|
|
286
375
|
else {
|
|
@@ -620,7 +709,8 @@ function wrapHtmlBody(html, allowRemote = false) {
|
|
|
620
709
|
<meta charset="UTF-8">
|
|
621
710
|
${csp}
|
|
622
711
|
<style>
|
|
623
|
-
html {
|
|
712
|
+
html, body { touch-action: pan-y pinch-zoom; }
|
|
713
|
+
html { height: 100%; overflow-y: auto; overflow-x: hidden; }
|
|
624
714
|
body {
|
|
625
715
|
font-family: system-ui, sans-serif;
|
|
626
716
|
font-size: 17.5px;
|
|
@@ -659,15 +749,43 @@ ${csp}
|
|
|
659
749
|
window.parent.postMessage({ type: "linkClick", url: url }, "*");
|
|
660
750
|
}
|
|
661
751
|
document.addEventListener("click", handleLinkTap, true);
|
|
752
|
+
// Android WebView fallback: some builds drop the synthetic click after
|
|
753
|
+
// touchend, so treat a stationary touchstart→touchend on the same link
|
|
754
|
+
// as a tap. Anything that moves more than TAP_SLOP pixels is a scroll
|
|
755
|
+
// and must NOT activate the link.
|
|
756
|
+
var TAP_SLOP = 10;
|
|
662
757
|
var lastTouchTarget = null;
|
|
758
|
+
var lastTouchX = 0;
|
|
759
|
+
var lastTouchY = 0;
|
|
760
|
+
var touchMoved = false;
|
|
761
|
+
// All touch listeners are passive so Android WebView can compositor-scroll
|
|
762
|
+
// the iframe without waiting on our JS. handleLinkTap's preventDefault only
|
|
763
|
+
// matters for the "click" path (which is non-passive by default).
|
|
663
764
|
document.addEventListener("touchstart", function (e) {
|
|
765
|
+
var t0 = e.touches && e.touches[0];
|
|
766
|
+
lastTouchX = t0 ? t0.clientX : 0;
|
|
767
|
+
lastTouchY = t0 ? t0.clientY : 0;
|
|
768
|
+
touchMoved = false;
|
|
664
769
|
lastTouchTarget = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
|
|
665
|
-
}, true);
|
|
770
|
+
}, { passive: true, capture: true });
|
|
771
|
+
document.addEventListener("touchmove", function (e) {
|
|
772
|
+
if (touchMoved) return;
|
|
773
|
+
var t = e.touches && e.touches[0];
|
|
774
|
+
if (!t) return;
|
|
775
|
+
if (Math.abs(t.clientX - lastTouchX) > TAP_SLOP || Math.abs(t.clientY - lastTouchY) > TAP_SLOP) {
|
|
776
|
+
touchMoved = true;
|
|
777
|
+
lastTouchTarget = null;
|
|
778
|
+
}
|
|
779
|
+
}, { passive: true, capture: true });
|
|
666
780
|
document.addEventListener("touchend", function (e) {
|
|
781
|
+
if (touchMoved) { lastTouchTarget = null; touchMoved = false; return; }
|
|
667
782
|
var t = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
|
|
668
783
|
if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);
|
|
669
784
|
lastTouchTarget = null;
|
|
670
|
-
}, true);
|
|
785
|
+
}, { passive: true, capture: true });
|
|
786
|
+
document.addEventListener("touchcancel", function () {
|
|
787
|
+
lastTouchTarget = null; touchMoved = false;
|
|
788
|
+
}, { passive: true, capture: true });
|
|
671
789
|
document.addEventListener("mouseover", function (e) {
|
|
672
790
|
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
673
791
|
if (a) {
|
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
/* Compose window styles */
|
|
2
2
|
|
|
3
|
+
.compose-modal-overlay {
|
|
4
|
+
position: fixed;
|
|
5
|
+
inset: 0;
|
|
6
|
+
background: rgba(0, 0, 0, 0.4);
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
z-index: 9999;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.compose-modal {
|
|
14
|
+
background: var(--color-bg, #fff);
|
|
15
|
+
border-radius: 6px;
|
|
16
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
|
17
|
+
padding: 20px 24px;
|
|
18
|
+
min-width: 320px;
|
|
19
|
+
max-width: 480px;
|
|
20
|
+
|
|
21
|
+
& .compose-modal-msg {
|
|
22
|
+
margin-bottom: 18px;
|
|
23
|
+
font-size: 14px;
|
|
24
|
+
color: var(--color-text, #222);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
& .compose-modal-buttons {
|
|
28
|
+
display: flex;
|
|
29
|
+
justify-content: flex-end;
|
|
30
|
+
gap: 8px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
& .compose-modal-btn {
|
|
34
|
+
padding: 6px 14px;
|
|
35
|
+
border: 1px solid var(--color-border, #ccc);
|
|
36
|
+
background: var(--color-bg-surface, #f6f6f6);
|
|
37
|
+
border-radius: 4px;
|
|
38
|
+
font: inherit;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
|
|
41
|
+
&:hover { background: var(--color-bg-hover, #ececec); }
|
|
42
|
+
|
|
43
|
+
&.primary {
|
|
44
|
+
background: #0b6bcb;
|
|
45
|
+
color: #fff;
|
|
46
|
+
border-color: #0b6bcb;
|
|
47
|
+
|
|
48
|
+
&:hover { background: #095aa8; }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
3
53
|
body {
|
|
4
54
|
display: flex;
|
|
5
55
|
flex-direction: column;
|
|
@@ -459,7 +459,25 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
459
459
|
catch (e) {
|
|
460
460
|
btn.disabled = false;
|
|
461
461
|
btn.textContent = "Send";
|
|
462
|
-
|
|
462
|
+
const msg = e?.message || String(e);
|
|
463
|
+
// Distinguish the IPC-timeout case from real send failures. The
|
|
464
|
+
// service-side send() queues the message to the local DB synchronously
|
|
465
|
+
// before attempting any IMAP/SMTP work — so if the IPC reached Node at
|
|
466
|
+
// all, the message is queued and the background worker will retry it
|
|
467
|
+
// with backoff (X-Mailx-Retry header, 60s settling delay, up to 10
|
|
468
|
+
// attempts). Treating that as a failure that demands a re-click leads
|
|
469
|
+
// to duplicate sends. Tell the user honestly: "probably queued, check
|
|
470
|
+
// Outbox before retrying."
|
|
471
|
+
if (msg.startsWith("mailxapi timeout")) {
|
|
472
|
+
alert("Send is taking longer than expected.\n\n" +
|
|
473
|
+
"The message has likely been queued and will be retried in the background. " +
|
|
474
|
+
"Check the Outbox folder before clicking Send again — clicking Send now may " +
|
|
475
|
+
"produce a duplicate.\n\n" +
|
|
476
|
+
"Your draft is preserved either way.");
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
alert(`Send failed: ${msg}`);
|
|
480
|
+
}
|
|
463
481
|
}
|
|
464
482
|
});
|
|
465
483
|
// ── Close handling ──
|
|
@@ -467,17 +485,55 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
467
485
|
function composeHasContent() {
|
|
468
486
|
return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
|
|
469
487
|
}
|
|
470
|
-
/** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel".
|
|
488
|
+
/** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel".
|
|
489
|
+
* Uses an in-page modal so all three choices are presented at once — the
|
|
490
|
+
* native confirm() flow forced the user through two sequential dialogs and
|
|
491
|
+
* hid Discard behind a Cancel click, which was confusing. */
|
|
471
492
|
function promptSaveOrDiscard() {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
493
|
+
return new Promise(resolve => {
|
|
494
|
+
const overlay = document.createElement("div");
|
|
495
|
+
overlay.className = "compose-modal-overlay";
|
|
496
|
+
const box = document.createElement("div");
|
|
497
|
+
box.className = "compose-modal";
|
|
498
|
+
const msg = document.createElement("div");
|
|
499
|
+
msg.className = "compose-modal-msg";
|
|
500
|
+
msg.textContent = "Save this message as a draft?";
|
|
501
|
+
const btnRow = document.createElement("div");
|
|
502
|
+
btnRow.className = "compose-modal-buttons";
|
|
503
|
+
const mkBtn = (label, choice, primary) => {
|
|
504
|
+
const b = document.createElement("button");
|
|
505
|
+
b.type = "button";
|
|
506
|
+
b.textContent = label;
|
|
507
|
+
b.className = primary ? "compose-modal-btn primary" : "compose-modal-btn";
|
|
508
|
+
b.addEventListener("click", () => { cleanup(); resolve(choice); });
|
|
509
|
+
return b;
|
|
510
|
+
};
|
|
511
|
+
const cleanup = () => {
|
|
512
|
+
document.removeEventListener("keydown", onKey);
|
|
513
|
+
overlay.remove();
|
|
514
|
+
};
|
|
515
|
+
const onKey = (e) => {
|
|
516
|
+
if (e.key === "Escape") {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
cleanup();
|
|
519
|
+
resolve("cancel");
|
|
520
|
+
}
|
|
521
|
+
else if (e.key === "Enter") {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
cleanup();
|
|
524
|
+
resolve("save");
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
document.addEventListener("keydown", onKey);
|
|
528
|
+
btnRow.appendChild(mkBtn("Save draft", "save", true));
|
|
529
|
+
btnRow.appendChild(mkBtn("Discard", "discard", false));
|
|
530
|
+
btnRow.appendChild(mkBtn("Cancel", "cancel", false));
|
|
531
|
+
box.appendChild(msg);
|
|
532
|
+
box.appendChild(btnRow);
|
|
533
|
+
overlay.appendChild(box);
|
|
534
|
+
document.body.appendChild(overlay);
|
|
535
|
+
btnRow.firstChild.focus();
|
|
536
|
+
});
|
|
481
537
|
}
|
|
482
538
|
/** Handle any "close the compose" action (Discard button, Escape, X, window close). */
|
|
483
539
|
async function handleCloseRequest() {
|
|
@@ -485,7 +541,7 @@ async function handleCloseRequest() {
|
|
|
485
541
|
closeCompose();
|
|
486
542
|
return true;
|
|
487
543
|
}
|
|
488
|
-
const choice = promptSaveOrDiscard();
|
|
544
|
+
const choice = await promptSaveOrDiscard();
|
|
489
545
|
if (choice === "cancel")
|
|
490
546
|
return false;
|
|
491
547
|
// Stop auto-save so it can't race with our explicit save/discard.
|
package/client/compose/editor.js
CHANGED
|
@@ -230,19 +230,63 @@ function createQuillEditor(container) {
|
|
|
230
230
|
openLinkForRange(q, q.getSelection() || { index: q.getLength() - 1, length: 0 });
|
|
231
231
|
});
|
|
232
232
|
// Paste handling:
|
|
233
|
-
// -
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
233
|
+
// - text/html clipboard with exactly one anchor (the common "copy a link
|
|
234
|
+
// with anchor text from a webpage" case): take it over from Quill —
|
|
235
|
+
// Quill's clipboard module was producing duplicates ("click here" as
|
|
236
|
+
// text PLUS a separate "https://example.com" as a link tail). Insert
|
|
237
|
+
// the anchor's text content as a single linked run.
|
|
238
|
+
// - text/html with richer content: defer to Quill (preserves formatting).
|
|
239
|
+
// - text/plain that's a URL: insert as a link, optionally wrapping any
|
|
240
|
+
// currently-selected text.
|
|
241
|
+
// - Anything else: default Quill behavior (verbatim plain or HTML).
|
|
238
242
|
q.root.addEventListener("paste", (e) => {
|
|
239
243
|
const cb = e.clipboardData;
|
|
240
244
|
if (!cb)
|
|
241
245
|
return;
|
|
242
246
|
const html = cb.getData("text/html");
|
|
243
247
|
const plain = cb.getData("text/plain");
|
|
244
|
-
if (html)
|
|
245
|
-
|
|
248
|
+
if (html) {
|
|
249
|
+
// Detect "single anchor" clipboard — copy from a browser usually
|
|
250
|
+
// produces something like:
|
|
251
|
+
// <meta charset='utf-8'><a href="https://example.com">click here</a>
|
|
252
|
+
// or wrapped in <html><body>. Parse and check.
|
|
253
|
+
try {
|
|
254
|
+
const tmp = document.createElement("div");
|
|
255
|
+
tmp.innerHTML = html;
|
|
256
|
+
// Strip script/style, then unwrap <html>/<body> noise.
|
|
257
|
+
const root = tmp.querySelector("body") || tmp;
|
|
258
|
+
// Walk for the only meaningful element
|
|
259
|
+
const meaningful = Array.from(root.childNodes).filter(n => {
|
|
260
|
+
if (n.nodeType === Node.TEXT_NODE)
|
|
261
|
+
return (n.textContent || "").trim().length > 0;
|
|
262
|
+
if (n.nodeType === Node.ELEMENT_NODE) {
|
|
263
|
+
const tag = n.tagName.toLowerCase();
|
|
264
|
+
return tag !== "meta" && tag !== "style" && tag !== "script";
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
});
|
|
268
|
+
if (meaningful.length === 1 && meaningful[0].tagName?.toLowerCase() === "a") {
|
|
269
|
+
const a = meaningful[0];
|
|
270
|
+
const href = a.getAttribute("href") || "";
|
|
271
|
+
const text = (a.textContent || "").trim();
|
|
272
|
+
if (href && text) {
|
|
273
|
+
e.preventDefault();
|
|
274
|
+
const range = q.getSelection(true);
|
|
275
|
+
if (!range)
|
|
276
|
+
return;
|
|
277
|
+
if (range.length > 0) {
|
|
278
|
+
// Selected text exists — replace with the linked anchor text
|
|
279
|
+
q.deleteText(range.index, range.length);
|
|
280
|
+
}
|
|
281
|
+
q.insertText(range.index, text, { link: href });
|
|
282
|
+
q.setSelection(range.index + text.length, 0);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch { /* fall through to Quill default */ }
|
|
288
|
+
return; // Quill handles richer HTML clipboard
|
|
289
|
+
}
|
|
246
290
|
if (plain && looksLikeUrl(plain)) {
|
|
247
291
|
e.preventDefault();
|
|
248
292
|
const range = q.getSelection(true);
|
package/client/index.html
CHANGED
|
@@ -38,8 +38,11 @@
|
|
|
38
38
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
39
39
|
<hr class="tb-menu-sep">
|
|
40
40
|
<label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
41
|
+
<label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
|
|
42
|
+
<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>
|
|
41
43
|
<hr class="tb-menu-sep">
|
|
42
44
|
<button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
|
|
45
|
+
<button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
|
|
43
46
|
</div>
|
|
44
47
|
</div>
|
|
45
48
|
<span id="app-version" class="app-version">mailx</span>
|
|
@@ -68,6 +71,22 @@
|
|
|
68
71
|
<button class="alert-dismiss" id="alert-dismiss" title="Dismiss">×</button>
|
|
69
72
|
</div>
|
|
70
73
|
|
|
74
|
+
<aside class="icon-rail" id="icon-rail" aria-label="App rail">
|
|
75
|
+
<div class="rail-top">
|
|
76
|
+
<button class="rail-btn" id="rail-compose" title="Compose (Ctrl+N)" aria-label="Compose">✏</button>
|
|
77
|
+
<button class="rail-btn" id="rail-inbox" title="Inbox" aria-label="Inbox" data-active="true">✉</button>
|
|
78
|
+
<button class="rail-btn" id="rail-unified" title="All Inboxes" aria-label="All Inboxes">⌘</button>
|
|
79
|
+
<button class="rail-btn" id="rail-contacts" title="Contacts (coming soon)" aria-label="Contacts" disabled>👤</button>
|
|
80
|
+
<button class="rail-btn" id="rail-calendar" title="Calendar (Phase 4)" aria-label="Calendar" disabled>📅</button>
|
|
81
|
+
<button class="rail-btn" id="rail-tasks" title="Tasks (Phase 4)" aria-label="Tasks" disabled>☑</button>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="rail-bottom">
|
|
84
|
+
<button class="rail-btn" id="rail-settings" title="Settings" aria-label="Settings">⚙</button>
|
|
85
|
+
<button class="rail-btn" id="rail-theme" title="Toggle theme" aria-label="Toggle theme">◐</button>
|
|
86
|
+
<button class="rail-btn" id="rail-help" title="Help / About" aria-label="Help">?</button>
|
|
87
|
+
</div>
|
|
88
|
+
</aside>
|
|
89
|
+
|
|
71
90
|
<div class="folder-panel">
|
|
72
91
|
<div class="ft-filter">
|
|
73
92
|
<input type="text" id="ft-filter-input" placeholder="Find folder..." autocomplete="off">
|
package/client/lib/api-client.js
CHANGED
|
@@ -161,6 +161,18 @@ export function readJsoncFile(name) {
|
|
|
161
161
|
export function writeJsoncFile(name, content) {
|
|
162
162
|
return ipc().writeJsoncFile?.(name, content);
|
|
163
163
|
}
|
|
164
|
+
export function readConfigHelp(name) {
|
|
165
|
+
return ipc().readConfigHelp?.(name) ?? Promise.resolve({ content: "" });
|
|
166
|
+
}
|
|
167
|
+
export function unsubscribeOneClick(url) {
|
|
168
|
+
return ipc().unsubscribeOneClick?.(url);
|
|
169
|
+
}
|
|
170
|
+
/** Run an AI text transform (translate / proofread / summarize). Returns
|
|
171
|
+
* empty `text` with a `reason` when the feature is disabled or the provider
|
|
172
|
+
* errors — caller should surface `reason` in a status bar, not throw. */
|
|
173
|
+
export function aiTransform(req) {
|
|
174
|
+
return ipc().aiTransform?.(req) ?? Promise.resolve({ text: "", reason: "AI not available in this host" });
|
|
175
|
+
}
|
|
164
176
|
export function setupAccount(name, email, password) {
|
|
165
177
|
return ipc().setupAccount?.(name, email, password);
|
|
166
178
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mailxapi — IPC bridge injected into WebView by the Rust launcher.
|
|
3
|
-
* Follows the msgapi pattern: callNode → Promise →
|
|
3
|
+
* Follows the msgapi pattern: callNode → Promise → _msgapiServiceResolve/Reject.
|
|
4
4
|
*
|
|
5
5
|
* When running in a browser (no IPC), this file is not loaded.
|
|
6
6
|
* api-client.ts auto-detects and falls back to HTTP fetch.
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// Called by Rust to resolve promises
|
|
35
|
-
window.
|
|
34
|
+
// Called by host (msger Rust / msgview preload) to resolve service-channel promises
|
|
35
|
+
window._msgapiServiceResolve = function(id, value) {
|
|
36
36
|
var cb = _callbacks[id];
|
|
37
37
|
if (!cb) return;
|
|
38
38
|
delete _callbacks[id];
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
cb.resolve(value);
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
// Called by
|
|
44
|
-
window.
|
|
43
|
+
// Called by host to reject service-channel promises
|
|
44
|
+
window._msgapiServiceReject = function(id, error) {
|
|
45
45
|
var cb = _callbacks[id];
|
|
46
46
|
if (!cb) return;
|
|
47
47
|
delete _callbacks[id];
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
cb.reject(new Error(error));
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
// Called by
|
|
53
|
-
window.
|
|
52
|
+
// Called by host to push events (new mail, sync progress, etc.)
|
|
53
|
+
window._msgapiServiceEvent = function(event) {
|
|
54
54
|
for (var i = 0; i < _eventHandlers.length; i++) {
|
|
55
55
|
try { _eventHandlers[i](event); } catch(e) { /* ignore */ }
|
|
56
56
|
}
|
|
@@ -109,6 +109,15 @@
|
|
|
109
109
|
writeJsoncFile: function(name, content) {
|
|
110
110
|
return callNode("writeJsoncFile", { name: name, content: content });
|
|
111
111
|
},
|
|
112
|
+
readConfigHelp: function(name) {
|
|
113
|
+
return callNode("readConfigHelp", { name: name });
|
|
114
|
+
},
|
|
115
|
+
unsubscribeOneClick: function(url) {
|
|
116
|
+
return callNode("unsubscribeOneClick", { url: url });
|
|
117
|
+
},
|
|
118
|
+
aiTransform: function(req) {
|
|
119
|
+
return callNode("aiTransform", req);
|
|
120
|
+
},
|
|
112
121
|
searchContacts: function(query) {
|
|
113
122
|
return callNode("searchContacts", { query: query });
|
|
114
123
|
},
|