@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.
@@ -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 sel = doc.defaultView?.getSelection();
131
- if (!sel)
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
- sel.removeAllRanges();
136
- sel.addRange(range);
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
- // - mailto: URLs open a pre-filled compose window (so the unsubscribe
247
- // reply gets sent from the correct mailx account, not the OS default
248
- // mail handler)
249
- // - https: URLs open a new tab
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 unsubUrl = msg.listUnsubscribe || "";
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 (unsubUrl) {
318
+ if (anyUrl) {
254
319
  unsubBtn.hidden = false;
255
- unsubBtn.textContent = "Unsubscribe";
256
- unsubBtn.title = unsubUrl;
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
- if (/^mailto:/i.test(unsubUrl)) {
261
- // Parse mailto:addr?subject=... and pre-fill compose
262
- const m = unsubUrl.match(/^mailto:([^?]*)(?:\?(.*))?$/i);
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 { height: 100%; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; }
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
- alert(`Send failed: ${e.message}`);
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
- // Three-way prompt built from two native dialogs: OK(save) / Cancel → then
473
- // for Cancel, ask "Really discard?" which becomes Discard / Cancel.
474
- const saveIt = confirm("Save this message as a draft?\n\nOK = Save as draft\nCancel = continue...");
475
- if (saveIt)
476
- return "save";
477
- const discard = confirm("Discard the message without saving?\n\nOK = Discard\nCancel = Keep editing");
478
- if (discard)
479
- return "discard";
480
- return "cancel";
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.
@@ -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
- // - If clipboard has text/html, let Quill convert it (preserves anchors).
234
- // - If only text/plain and it's a URL, insert as a link (honoring any text
235
- // currently selected the URL becomes the href of that text).
236
- // - Shift+Ctrl+V (handled via contextmenu "Paste as text") or any other
237
- // plain text is inserted verbatim.
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
- return; // Quill handles HTML clipboard natively
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">&times;</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">
@@ -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
  }
@@ -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 → _mailxapiResolve/Reject.
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._mailxapiResolve = function(id, value) {
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 Rust to reject promises
44
- window._mailxapiReject = function(id, error) {
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 Rust to push events (new mail, sync progress, etc.)
53
- window._mailxapiEvent = function(event) {
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
  },