@bobfrankston/mailx 1.0.310 → 1.0.317

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 CHANGED
@@ -123,11 +123,16 @@ Gmail OAuth requires a one-time Google Cloud setup:
123
123
 
124
124
  ## Usage
125
125
 
126
+ ### Layout
127
+
128
+ A vertical **icon rail** sits on the far left (Thunderbird / Dovecot style) with one-click access to Compose, Inbox, All Inboxes, and Settings; Calendar / Tasks / Contacts slots are placeholders until those features land. The rail is always visible on wide and medium screens; it folds into the hamburger menu on narrow ones.
129
+
126
130
  ### Reading Mail
127
131
 
128
132
  - Click a **folder** to see its messages
129
133
  - Click a **message** to read it in the preview pane
130
134
  - **All Inboxes** combines inboxes from all accounts (appears with 2+ accounts)
135
+ - A small filled teal dot before the date means the message body is downloaded locally (offline-ready); a hollow circle means not yet prefetched. Prefetch runs as a background task every ~60 s independent of folder sync.
131
136
  - Unread counts show on folders; sub-folder counts bubble up to collapsed parents
132
137
  - Use the **folder search** box to find folders by name
133
138
 
@@ -150,15 +155,18 @@ Gmail OAuth requires a one-time Google Cloud setup:
150
155
 
151
156
  ### Managing Messages
152
157
 
153
- - **Delete** or **Ctrl+D** -- Delete selected messages (moves to Trash)
158
+ - **Delete** or **Ctrl+D** -- Delete selected messages (moves to Trash; second delete in Trash is a hard delete + EXPUNGE)
154
159
  - **Ctrl+Z** -- Undo the last **delete or move** (whichever came last, 60s window)
155
160
  - **Ctrl+A** -- Select all messages in the list
156
161
  - **Drag and drop** -- Move messages to a folder by dragging them
162
+ - **Right-click a message → Move to folder…** -- Searchable folder picker; useful on narrow layouts where the folder tree is hidden
157
163
  - Click the **star** column to flag/unflag a message
158
- - **Unsubscribe** button appears when the message has a List-Unsubscribe header (one-click)
164
+ - **Unsubscribe** button appears when the message has a List-Unsubscribe header. One-click (RFC 8058) when the sender advertises `List-Unsubscribe-Post: List-Unsubscribe=One-Click`; otherwise opens the URL or a pre-filled compose for `mailto:` lists.
159
165
  - **Right-click on a From/To/Cc address** -- Copy name, Copy address, Copy both, Add to contacts, or Reply/Reply All/Forward
166
+ - **Right-click in the message body → Translate** (opt-in) -- Uses the configured AI provider; select text first to translate just the selection. Off by default; enable under **Settings → AI translate**.
160
167
  - **Preview pane zoom** -- Ctrl+wheel, Ctrl+= / Ctrl+- / Ctrl+0, or right-click menu (Zoom in/out/reset, Copy, Select all). Persisted across messages.
161
168
  - **Cross-folder search results** show the folder name for each hit
169
+ - **Empty Trash / Empty Junk** -- Right-click the folder in the tree → Empty (confirmation prompt)
162
170
 
163
171
  ### Searching
164
172
 
@@ -191,6 +199,10 @@ Under **View** in the toolbar:
191
199
  Under **Settings** in the toolbar:
192
200
  - **Editor** -- Choose between Quill (default) and tiptap for compose
193
201
  - **AI autocomplete** -- Enable LLM-powered writing suggestions (Ollama, Claude, or OpenAI)
202
+ - **AI translate** -- Enable the right-click Translate item in the message viewer (off by default; uses the same provider as autocomplete)
203
+ - **AI proofread** -- Enable the proofread path (off by default; provider method available, UI wiring in progress)
204
+
205
+ All AI features are opt-in. Provider + API key live in the autocomplete settings; toggling a feature only controls whether that specific capability calls out to the provider.
194
206
 
195
207
  ### Keyboard Shortcuts
196
208
 
package/bin/mailx.js CHANGED
@@ -21,8 +21,9 @@ import path from "node:path";
21
21
  import os from "node:os";
22
22
  import net from "node:net";
23
23
  import { ports } from "@bobfrankston/miscinfo";
24
- import { showMessageBox, showService, setAppName } from "@bobfrankston/mailx-host";
24
+ import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
25
25
  setAppName("mailx");
26
+ setAppIcon(path.resolve(import.meta.dirname, "..", "client", "icon.png"));
26
27
  const PORT = ports.mailx;
27
28
  const args = process.argv.slice(2);
28
29
  // Normalize: accept both -flag and --flag
@@ -146,6 +147,46 @@ for (const arg of args) {
146
147
  }
147
148
  function log(...msg) { if (verbose)
148
149
  console.log("[mailx]", ...msg); }
150
+ /** Detect whether we're running with administrator / root privileges.
151
+ * Windows: `net session` requires admin — succeeds silently when elevated,
152
+ * errors "Access is denied" otherwise. Linux/Mac: check process uid.
153
+ * Returns true only when positively detected as elevated; on ambiguity
154
+ * (e.g. child_process spawn failed for non-privilege reasons), returns
155
+ * false so we don't block users on false positives. */
156
+ function isElevated() {
157
+ try {
158
+ if (process.platform === "win32") {
159
+ const { execSync } = require("node:child_process");
160
+ execSync("net session >nul 2>&1", { stdio: "ignore", windowsHide: true });
161
+ return true;
162
+ }
163
+ if (typeof process.getuid === "function") {
164
+ return process.getuid() === 0;
165
+ }
166
+ }
167
+ catch { /* non-admin → net session fails */ }
168
+ return false;
169
+ }
170
+ /** Put up a blocking warning dialog via showMessageBox. Returns the label
171
+ * the user clicked. The default (Quit) is first so Enter dismisses to
172
+ * safety. Caller decides what to do with "Continue anyway". */
173
+ async function warnElevated() {
174
+ const res = await showMessageBox({
175
+ title: "mailx — elevated run not recommended",
176
+ message: "mailx is running with Administrator privileges.\n\n" +
177
+ "This can corrupt the per-user WebView2 profile at\n" +
178
+ "%LOCALAPPDATA%\\msger\\webview2\\ and create admin-owned files\n" +
179
+ "under ~/.mailx/ that later non-admin runs can't write to\n" +
180
+ "(SQLite db, tokens, config).\n\n" +
181
+ "Quit, relaunch from a normal shell, and only use admin if\n" +
182
+ "you specifically know you need it. To bypass this warning\n" +
183
+ "(for scripted admin use), pass --allow-elevated.",
184
+ buttons: ["Quit", "Continue anyway"],
185
+ size: { width: 540, height: 340 },
186
+ escapeCloses: true,
187
+ });
188
+ return res.button;
189
+ }
149
190
  // Kill any running mailx server
150
191
  if (hasFlag("kill")) {
151
192
  log("Killing mailx processes...");
@@ -731,6 +772,20 @@ async function main() {
731
772
  log(`Platform: ${process.platform} ${process.arch}`);
732
773
  log(`Node: ${process.version}`);
733
774
  log(`Mode: ${setupMode ? "setup" : "auto"}`);
775
+ // Refuse to run elevated unless explicitly opted in. An elevated mailx
776
+ // can poison %LOCALAPPDATA%\msger\webview2\ (see msger/notes.md WebView2
777
+ // profile playbook) and create admin-owned files under ~/.mailx/ that
778
+ // later non-admin runs can't write to. `net session` requires admin on
779
+ // Windows; succeeds → admin, fails → non-admin. Linux/Mac use process
780
+ // uid (0 = root). --allow-elevated bypasses for scripted admin use.
781
+ if (!hasFlag("allow-elevated") && !isDaemon && isElevated()) {
782
+ const button = await warnElevated();
783
+ if (button !== "Continue anyway") {
784
+ log("User chose Quit on elevated-run warning. Exiting.");
785
+ process.exit(0);
786
+ }
787
+ log("User chose Continue anyway on elevated-run warning. Proceeding (will likely poison local state).");
788
+ }
734
789
  // Test connectivity
735
790
  if (testMode) {
736
791
  await runTest();
package/client/app.js CHANGED
@@ -415,6 +415,28 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
415
415
  });
416
416
  async function openCompose(mode) {
417
417
  const current = getCurrentMessage();
418
+ // Reply / Reply-All / Forward all need an original message to populate
419
+ // From, To, Subject, and the quoted body. Two failure modes used to
420
+ // silently produce a blank compose:
421
+ // (1) getCurrentMessage() returns null — viewer still loading, message
422
+ // cleared mid-folder-switch, or fetch failed.
423
+ // (2) currentMessage is set but is a stub — header metadata arrived
424
+ // but body / from / subject haven't been populated yet.
425
+ // Bail out in both cases instead of opening an empty form.
426
+ if (mode === "reply" || mode === "replyAll" || mode === "forward") {
427
+ const m = current?.message;
428
+ const stubReason = !current ? "no current message" :
429
+ !m?.from ? "msg.from missing" :
430
+ !m?.subject && m?.subject !== "" ? "msg.subject missing" :
431
+ (mode !== "forward" && !m?.messageId) ? "msg.messageId missing (can't thread reply)" :
432
+ null;
433
+ if (stubReason) {
434
+ console.warn(`[compose] ${mode} ignored — ${stubReason}; current=`, current);
435
+ alert(`Cannot ${mode === "forward" ? "forward" : "reply to"} this message yet — ` +
436
+ `it's still loading (${stubReason}). Please wait a moment and try again.`);
437
+ return;
438
+ }
439
+ }
418
440
  const accounts = await getAccounts();
419
441
  const accountId = current?.accountId || accounts[0]?.id || "";
420
442
  const msg = current?.message;
@@ -431,20 +453,34 @@ async function openCompose(mode) {
431
453
  references: [],
432
454
  accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email })),
433
455
  };
434
- // Auto-detect reply From: if the message was delivered to an identity domain,
435
- // reply from that address instead of the default account address.
436
- // Identity domains configured per-account in accounts.jsonc (identityDomains array).
456
+ // Auto-detect reply From: if the message was delivered to an identity address
457
+ // (an alias on the account's domain, or the explicit `identityDomains` list
458
+ // in accounts.jsonc), reply from that address instead of the account's
459
+ // primary. Always derive identityDomains from the account email's domain
460
+ // when not configured — explicit list was a regression source (users would
461
+ // see Reply pick the wrong From silently when the list was missing).
437
462
  const account = accounts.find((a) => a.id === accountId);
438
- const identityDomains = account?.identityDomains || [];
463
+ const explicitDomains = (account?.identityDomains || []).map((d) => d.toLowerCase());
464
+ const accountDomain = (account?.email || "").split("@")[1]?.toLowerCase();
465
+ const identityDomains = explicitDomains.length > 0
466
+ ? explicitDomains
467
+ : (accountDomain ? [accountDomain] : []);
439
468
  function detectReplyFrom() {
440
469
  if (!msg || identityDomains.length === 0)
441
470
  return undefined;
442
- const candidates = [msg.deliveredTo, ...(msg.to || []).map((a) => a.address)].filter(Boolean);
443
- console.log(`[compose] detectReplyFrom: deliveredTo=${msg.deliveredTo}, to=${msg.to?.map((a) => a.address)}, domains=${identityDomains}`);
471
+ // Prefer Delivered-To header (the address the server actually delivered
472
+ // to, which is the alias the message arrived at). Fall back to To, then
473
+ // Cc, in order. Bcc isn't visible to recipients so skipped.
474
+ const candidates = [
475
+ msg.deliveredTo,
476
+ ...((msg.to || []).map((a) => a.address)),
477
+ ...((msg.cc || []).map((a) => a.address)),
478
+ ].filter(Boolean);
479
+ console.log(`[compose] detectReplyFrom: deliveredTo=${msg.deliveredTo}, to=${msg.to?.map((a) => a.address)}, cc=${msg.cc?.map((a) => a.address)}, identityDomains=${identityDomains}, accountEmail=${account?.email}`);
444
480
  for (const addr of candidates) {
445
481
  const domain = addr.split("@")[1]?.toLowerCase();
446
- if (domain && identityDomains.some((d) => domain === d.toLowerCase())) {
447
- console.log(`[compose] matched: ${addr}`);
482
+ if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
483
+ console.log(`[compose] reply From → ${addr}`);
448
484
  return addr;
449
485
  }
450
486
  }
@@ -789,6 +825,45 @@ document.getElementById("btn-compose")?.addEventListener("click", () => openComp
789
825
  document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
790
826
  document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
791
827
  document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
828
+ // ── Icon rail wiring ──
829
+ // Rail is the always-visible vertical bar on the far left (Thunderbird/Dovecot
830
+ // style). Mostly mirrors toolbar/menu actions for one-click access; calendar /
831
+ // tasks / contacts buttons are placeholders until those features ship.
832
+ document.getElementById("rail-compose")?.addEventListener("click", () => openCompose("new"));
833
+ document.getElementById("rail-inbox")?.addEventListener("click", () => {
834
+ // Trigger the existing folder-tree click on the first inbox folder.
835
+ const inbox = document.querySelector('.folder-tree .folder-item[data-special-use="inbox"]');
836
+ inbox?.click();
837
+ });
838
+ document.getElementById("rail-unified")?.addEventListener("click", () => {
839
+ const unified = document.querySelector('.folder-tree .all-inboxes')
840
+ || document.getElementById("ft-all-inboxes");
841
+ unified?.click();
842
+ });
843
+ document.getElementById("rail-settings")?.addEventListener("click", () => {
844
+ document.getElementById("btn-settings")?.click();
845
+ });
846
+ document.getElementById("rail-help")?.addEventListener("click", () => {
847
+ document.getElementById("btn-about")?.click();
848
+ });
849
+ document.getElementById("rail-theme")?.addEventListener("click", () => {
850
+ // Cycle theme: system → dark → light → system.
851
+ const root = document.documentElement;
852
+ const cur = root.getAttribute("data-theme") || "system";
853
+ const next = cur === "system" ? "dark" : cur === "dark" ? "light" : "system";
854
+ root.setAttribute("data-theme", next);
855
+ try {
856
+ localStorage.setItem("mailx-theme", next);
857
+ }
858
+ catch { /* private mode */ }
859
+ });
860
+ // Highlight the current rail target. For now just inbox is the default; once
861
+ // calendar/tasks ship, update this on view change.
862
+ function setRailActive(id) {
863
+ document.querySelectorAll(".rail-btn[data-active]").forEach(el => el.removeAttribute("data-active"));
864
+ document.getElementById(id)?.setAttribute("data-active", "true");
865
+ }
866
+ document.addEventListener("mailx-folder-changed", () => setRailActive("rail-inbox"));
792
867
  // Context menu events from message-list right-click
793
868
  document.addEventListener("mailx-compose", ((e) => {
794
869
  if (e.detail.mode === "draft" && sessionStorage.getItem("composeInit")) {
@@ -1907,19 +1982,30 @@ optEditorTiptap?.addEventListener("change", () => {
1907
1982
  if (optEditorTiptap.checked)
1908
1983
  saveEditorSetting("tiptap");
1909
1984
  });
1910
- // ── AI autocomplete toggle ──
1985
+ // ── AI feature toggles ──
1986
+ // One umbrella settings record (AutocompleteSettings) holds the provider config
1987
+ // + per-feature on/off flags. All features default OFF — user must opt into
1988
+ // each AI behavior individually. Per user preference (2026-04-21).
1911
1989
  const optAutocomplete = document.getElementById("opt-autocomplete");
1912
- // Load current autocomplete setting
1990
+ const optAiTranslate = document.getElementById("opt-ai-translate");
1991
+ const optAiProofread = document.getElementById("opt-ai-proofread");
1913
1992
  getAutocompleteSettings().then((ac) => {
1914
1993
  if (optAutocomplete)
1915
- optAutocomplete.checked = ac.enabled || false;
1994
+ optAutocomplete.checked = !!ac.enabled;
1995
+ if (optAiTranslate)
1996
+ optAiTranslate.checked = !!ac.translateEnabled;
1997
+ if (optAiProofread)
1998
+ optAiProofread.checked = !!ac.proofreadEnabled;
1916
1999
  }).catch(() => { });
1917
- optAutocomplete?.addEventListener("change", () => {
2000
+ function persistAi(mutator) {
1918
2001
  getAutocompleteSettings().then((ac) => {
1919
- ac.enabled = optAutocomplete.checked;
2002
+ mutator(ac);
1920
2003
  saveAutocompleteSettings(ac);
1921
2004
  }).catch(() => { });
1922
- });
2005
+ }
2006
+ optAutocomplete?.addEventListener("change", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));
2007
+ optAiTranslate?.addEventListener("change", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));
2008
+ optAiProofread?.addEventListener("change", () => persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; }));
1923
2009
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
1924
2010
  // Wait for server ready signal, then fetch version
1925
2011
  const versionPromise = getVersion();
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Folder picker — small modal for choosing a destination folder.
3
+ * Used by the message-list right-click "Move to folder…" item and any
4
+ * other UI that needs the user to pick a folder.
5
+ *
6
+ * Reads folders from the local DB via getFolders() (local-first — no
7
+ * server round-trip). Filters by typed text. Returns the selected
8
+ * folder, or null if the user dismissed.
9
+ */
10
+ import { getFolders } from "../lib/api-client.js";
11
+ /** Show a modal folder picker. Returns a promise resolving to the picked
12
+ * folder, or null if dismissed. The list is restricted to one account
13
+ * (the current message's account) so it doesn't get cluttered with
14
+ * unrelated folders; cross-account moves can be added later via an
15
+ * account selector at the top of the picker. */
16
+ export function pickFolder(accountId, opts) {
17
+ return new Promise(async (resolve) => {
18
+ const overlay = document.createElement("div");
19
+ overlay.className = "folder-picker-overlay";
20
+ 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;";
21
+ const modal = document.createElement("div");
22
+ modal.className = "folder-picker-modal";
23
+ modal.style.cssText = "background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,0.3);width:380px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;";
24
+ const header = document.createElement("div");
25
+ header.style.cssText = "padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;";
26
+ header.textContent = opts?.title || "Move to folder…";
27
+ modal.appendChild(header);
28
+ const search = document.createElement("input");
29
+ search.type = "text";
30
+ search.placeholder = "Filter folders…";
31
+ search.style.cssText = "margin:8px 12px;padding:6px 10px;border:1px solid var(--border, #ccc);border-radius:4px;font-size:13px;";
32
+ modal.appendChild(search);
33
+ const listEl = document.createElement("div");
34
+ listEl.style.cssText = "flex:1;overflow-y:auto;padding:4px 0;";
35
+ modal.appendChild(listEl);
36
+ const footer = document.createElement("div");
37
+ footer.style.cssText = "padding:8px 12px;border-top:1px solid var(--border, #ddd);display:flex;justify-content:flex-end;gap:8px;";
38
+ const cancelBtn = document.createElement("button");
39
+ cancelBtn.textContent = "Cancel";
40
+ cancelBtn.style.cssText = "padding:6px 14px;cursor:pointer;";
41
+ footer.appendChild(cancelBtn);
42
+ modal.appendChild(footer);
43
+ overlay.appendChild(modal);
44
+ document.body.appendChild(overlay);
45
+ const dismiss = (result) => {
46
+ overlay.remove();
47
+ document.removeEventListener("keydown", onKey);
48
+ resolve(result);
49
+ };
50
+ const onKey = (e) => {
51
+ if (e.key === "Escape") {
52
+ e.preventDefault();
53
+ dismiss(null);
54
+ }
55
+ if (e.key === "Enter") {
56
+ const first = listEl.querySelector(".folder-picker-row.match");
57
+ if (first)
58
+ first.click();
59
+ }
60
+ };
61
+ document.addEventListener("keydown", onKey);
62
+ overlay.addEventListener("click", (e) => { if (e.target === overlay)
63
+ dismiss(null); });
64
+ cancelBtn.addEventListener("click", () => dismiss(null));
65
+ // Local-first: load from DB synchronously-ish (one IPC round-trip).
66
+ let folders = [];
67
+ try {
68
+ folders = (await getFolders(accountId)) || [];
69
+ }
70
+ catch (e) {
71
+ listEl.textContent = "Failed to load folders";
72
+ return;
73
+ }
74
+ // Hide special-use that don't make sense as targets (Outbox).
75
+ // Allow Trash / Junk so users can manually file into them.
76
+ const excluded = new Set(opts?.excludeFolderIds || []);
77
+ const targets = folders
78
+ .filter((f) => !excluded.has(f.id))
79
+ .filter((f) => f.specialUse !== "outbox")
80
+ .sort((a, b) => a.path.localeCompare(b.path));
81
+ function render(filter) {
82
+ listEl.innerHTML = "";
83
+ const lc = filter.toLowerCase().trim();
84
+ let firstMatchSet = false;
85
+ for (const f of targets) {
86
+ const row = document.createElement("div");
87
+ row.className = "folder-picker-row";
88
+ row.style.cssText = "padding:6px 14px;cursor:pointer;font-size:13px;display:flex;justify-content:space-between;gap:8px;";
89
+ const name = document.createElement("span");
90
+ name.textContent = f.path;
91
+ const tag = document.createElement("span");
92
+ tag.style.cssText = "color:var(--muted, #888);font-size:11px;";
93
+ tag.textContent = f.specialUse || "";
94
+ row.appendChild(name);
95
+ row.appendChild(tag);
96
+ const matches = !lc || f.path.toLowerCase().includes(lc);
97
+ if (matches) {
98
+ row.classList.add("match");
99
+ if (!firstMatchSet) {
100
+ row.style.background = "var(--hover, #eee)";
101
+ firstMatchSet = true;
102
+ }
103
+ }
104
+ row.addEventListener("mouseenter", () => row.style.background = "var(--hover, #eee)");
105
+ row.addEventListener("mouseleave", () => row.style.background = "");
106
+ row.addEventListener("click", () => {
107
+ dismiss({ accountId, folderId: f.id, folderPath: f.path, folderName: f.path.split(/[./]/).pop() || f.path });
108
+ });
109
+ if (!matches)
110
+ row.style.display = "none";
111
+ listEl.appendChild(row);
112
+ }
113
+ }
114
+ render("");
115
+ search.addEventListener("input", () => render(search.value));
116
+ setTimeout(() => search.focus(), 0);
117
+ });
118
+ }
119
+ //# sourceMappingURL=folder-picker.js.map
@@ -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) },