@bobfrankston/mailx 1.0.310 → 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 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) },
@@ -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 ──
@@ -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,6 +38,8 @@
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>
43
45
  <button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
@@ -69,6 +71,22 @@
69
71
  <button class="alert-dismiss" id="alert-dismiss" title="Dismiss">&times;</button>
70
72
  </div>
71
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
+
72
90
  <div class="folder-panel">
73
91
  <div class="ft-filter">
74
92
  <input type="text" id="ft-filter-input" placeholder="Find folder..." autocomplete="off">
@@ -167,6 +167,12 @@ export function readConfigHelp(name) {
167
167
  export function unsubscribeOneClick(url) {
168
168
  return ipc().unsubscribeOneClick?.(url);
169
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
+ }
170
176
  export function setupAccount(name, email, password) {
171
177
  return ipc().setupAccount?.(name, email, password);
172
178
  }
@@ -115,6 +115,9 @@
115
115
  unsubscribeOneClick: function(url) {
116
116
  return callNode("unsubscribeOneClick", { url: url });
117
117
  },
118
+ aiTransform: function(req) {
119
+ return callNode("aiTransform", req);
120
+ },
118
121
  searchContacts: function(query) {
119
122
  return callNode("searchContacts", { query: query });
120
123
  },
@@ -8,13 +8,14 @@
8
8
 
9
9
  body {
10
10
  display: grid;
11
- grid-template-columns: var(--folder-width) 1fr;
11
+ /* rail | folders | main */
12
+ grid-template-columns: var(--rail-width, 48px) var(--folder-width) 1fr;
12
13
  grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
13
14
  grid-template-areas:
14
- "toolbar toolbar"
15
- "alert alert"
16
- "folders main"
17
- "status status";
15
+ "toolbar toolbar toolbar"
16
+ "alert alert alert"
17
+ "rail folders main"
18
+ "status status status";
18
19
  height: 100vh;
19
20
  overflow: hidden;
20
21
  font-family: var(--font-ui);
@@ -25,11 +26,55 @@ body {
25
26
  }
26
27
 
27
28
  .toolbar { grid-area: toolbar; }
29
+ .icon-rail { grid-area: rail; }
28
30
  .folder-panel { grid-area: folders; display: flex; flex-direction: column; overflow: hidden; }
29
31
  .folder-tree { flex: 1; overflow-y: auto; }
30
32
  .main-area { grid-area: main; }
31
33
  .status-bar { grid-area: status; }
32
34
 
35
+ /* Vertical icon rail (Dovecot/Thunderbird-style). Always visible on
36
+ wide+medium tiers; collapses on narrow (icons move into the hamburger
37
+ menu — TBD; for now hidden on narrow). */
38
+ .icon-rail {
39
+ display: flex;
40
+ flex-direction: column;
41
+ justify-content: space-between;
42
+ background: var(--color-bg-alt, #f4f4f5);
43
+ border-right: 1px solid var(--color-border);
44
+ padding: 6px 0;
45
+ overflow: hidden;
46
+ }
47
+ .rail-top, .rail-bottom {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 2px;
51
+ }
52
+ .rail-btn {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ width: var(--rail-width, 48px);
57
+ height: 38px;
58
+ border: 0;
59
+ background: transparent;
60
+ cursor: pointer;
61
+ font-size: 16px;
62
+ color: var(--color-text);
63
+ border-left: 3px solid transparent;
64
+ transition: background 0.12s, border-color 0.12s;
65
+ }
66
+ .rail-btn:hover:not([disabled]) {
67
+ background: var(--color-hover, rgba(0,0,0,0.06));
68
+ }
69
+ .rail-btn[data-active="true"] {
70
+ background: var(--color-hover, rgba(0,0,0,0.06));
71
+ border-left-color: var(--color-accent, #1a6dd4);
72
+ }
73
+ .rail-btn[disabled] {
74
+ opacity: 0.35;
75
+ cursor: not-allowed;
76
+ }
77
+
33
78
  /* Main area: message list left, viewer right, vertical splitter */
34
79
  .main-area {
35
80
  display: grid;
@@ -56,22 +101,22 @@ body {
56
101
  background: var(--color-accent);
57
102
  }
58
103
 
59
- /* Responsive: mid-width (tablets, foldables) — hide folders, keep list + preview */
104
+ /* Responsive: mid-width (tablets, foldables) — keep rail + list + viewer; folders overlay */
60
105
  @media (max-width: 1100px) and (min-width: 769px) {
61
106
  body {
62
- grid-template-columns: 1fr;
107
+ grid-template-columns: var(--rail-width, 48px) 1fr;
63
108
  grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
64
109
  grid-template-areas:
65
- "toolbar"
66
- "alert"
67
- "main"
68
- "status";
110
+ "toolbar toolbar"
111
+ "alert alert"
112
+ "rail main"
113
+ "status status";
69
114
  }
70
115
 
71
- /* Folder panel: overlay slide-in from left (same as narrow) */
116
+ /* Folder panel: overlay slide-in from left, sitting just to the right of the rail */
72
117
  .folder-panel {
73
118
  position: fixed;
74
- left: -280px;
119
+ left: calc(var(--rail-width, 48px) - 280px);
75
120
  top: var(--toolbar-height);
76
121
  bottom: var(--statusbar-height);
77
122
  width: 280px;
@@ -81,7 +126,7 @@ body {
81
126
  border-right: 1px solid var(--color-border);
82
127
  box-shadow: 2px 0 8px rgba(0,0,0,0.3);
83
128
  }
84
- .folder-panel.open { left: 0; }
129
+ .folder-panel.open { left: var(--rail-width, 48px); }
85
130
 
86
131
  /* Show hamburger */
87
132
  #btn-menu { display: inline-flex !important; }
@@ -122,6 +167,11 @@ body {
122
167
  "status";
123
168
  }
124
169
 
170
+ /* Rail hidden on narrow — its commands fold into the hamburger / toolbar.
171
+ Future work: a slide-in rail behind the hamburger so power-users on phone
172
+ can still reach calendar/contacts/etc. */
173
+ .icon-rail { display: none; }
174
+
125
175
  /* Folder panel: overlay slide-in from left */
126
176
  .folder-panel {
127
177
  position: fixed;
@@ -3,6 +3,7 @@
3
3
 
4
4
  :root {
5
5
  /* Layout */
6
+ --rail-width: 44px; /* vertical icon rail (Thunderbird/Dovecot style) */
6
7
  --folder-width: 220px;
7
8
  --toolbar-height: 44px;
8
9
  --statusbar-height: 24px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.310",
3
+ "version": "1.0.313",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -22,10 +22,10 @@
22
22
  "dependencies": {
23
23
  "@bobfrankston/iflow-direct": "^0.1.23",
24
24
  "@bobfrankston/iflow-node": "^0.1.7",
25
- "@bobfrankston/miscinfo": "^1.0.8",
25
+ "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.340",
28
- "@bobfrankston/mailx-host": "^0.1.0",
27
+ "@bobfrankston/msger": "^0.1.342",
28
+ "@bobfrankston/mailx-host": "^0.1.3",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
31
31
  "@capacitor/core": "^8.3.0",
@@ -86,10 +86,10 @@
86
86
  "dependencies": {
87
87
  "@bobfrankston/iflow-direct": "^0.1.23",
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
- "@bobfrankston/miscinfo": "^1.0.8",
89
+ "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.340",
92
- "@bobfrankston/mailx-host": "^0.1.0",
91
+ "@bobfrankston/msger": "^0.1.342",
92
+ "@bobfrankston/mailx-host": "^0.1.3",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
95
95
  "@capacitor/core": "^8.3.0",
@@ -16,5 +16,6 @@ export declare function selectHost(): HostName;
16
16
  export declare const showMessageBox: typeof msger.showMessageBox;
17
17
  export declare const showService: typeof msger.showService;
18
18
  export declare const setAppName: typeof msger.setAppName;
19
+ export declare const setAppIcon: typeof msger.setAppIcon;
19
20
  export declare const hostName: HostName;
20
21
  //# sourceMappingURL=index.d.ts.map
@@ -26,5 +26,6 @@ if (_hostName !== "msger") {
26
26
  export const showMessageBox = msger.showMessageBox;
27
27
  export const showService = msger.showService;
28
28
  export const setAppName = msger.setAppName;
29
+ export const setAppIcon = msger.setAppIcon;
29
30
  export const hostName = _hostName;
30
31
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-host",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Host abstraction for mailx — dispatches to msger or msgview",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -16,5 +16,8 @@
16
16
  "repository": {
17
17
  "type": "git",
18
18
  "url": "git@github.com:BobFrankston/mailx-host.git"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
19
22
  }
20
23
  }
@@ -964,6 +964,18 @@ export class ImapManager extends EventEmitter {
964
964
  if (!inboxDone) {
965
965
  console.error(` [sync] ${accountId}: INBOX failed after ${maxAttempts} attempts — will retry next sync cycle`);
966
966
  this.emit("syncError", accountId, `INBOX sync failed after ${maxAttempts} attempts`);
967
+ // Even when sync failed, try to prefetch bodies for messages
968
+ // already in the local DB. Prefetch uses a separate body
969
+ // client (not the ops client that just timed out), so a
970
+ // sync timeout on SELECT/SEARCH doesn't necessarily mean
971
+ // body fetches will also fail. Without this, a server
972
+ // having a slow patch would leave every message with a
973
+ // white "not-downloaded" dot indefinitely until sync
974
+ // recovers — even though prior syncs already populated
975
+ // headers that prefetch can flesh out independently.
976
+ if (getPrefetch()) {
977
+ this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
978
+ }
967
979
  }
968
980
  }
969
981
  else {
@@ -1431,6 +1443,27 @@ export class ImapManager extends EventEmitter {
1431
1443
  }
1432
1444
  }, 30000);
1433
1445
  this.syncIntervals.set("actions", actionsInterval);
1446
+ // Body prefetch as a first-class background task — independent of
1447
+ // sync success. Prefetch was previously only triggered from inside
1448
+ // sync, so any account with slow/failing IMAP had its "not downloaded"
1449
+ // dots stuck forever even though body fetches use a separate
1450
+ // connection that might succeed. Every 60s, for every account, fire
1451
+ // prefetchBodies() (cheap when body_path is already populated — just a
1452
+ // DB query that returns 0 rows; the prefetchingAccounts guard
1453
+ // short-circuits concurrent triggers).
1454
+ if (getPrefetch()) {
1455
+ const kickPrefetch = () => {
1456
+ for (const [accountId] of this.configs) {
1457
+ this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e?.message || e}`));
1458
+ }
1459
+ };
1460
+ // Fire once now so the "not downloaded" dots start filling in
1461
+ // immediately on app start, don't make the user wait a minute.
1462
+ setTimeout(kickPrefetch, 2000);
1463
+ const prefetchInterval = setInterval(kickPrefetch, 60000);
1464
+ this.syncIntervals.set("prefetch", prefetchInterval);
1465
+ console.log(` [periodic] body prefetch every 60s (independent of sync)`);
1466
+ }
1434
1467
  // Full sync (all folders + IDLE restart) at configured interval
1435
1468
  const fullInterval = setInterval(async () => {
1436
1469
  console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { MailxDB } from "@bobfrankston/mailx-store";
7
7
  import { ImapManager } from "@bobfrankston/mailx-imap";
8
- import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings } from "@bobfrankston/mailx-types";
8
+ import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse } from "@bobfrankston/mailx-types";
9
9
  export declare class MailxService {
10
10
  private db;
11
11
  private imapManager;
@@ -101,5 +101,10 @@ export declare class MailxService {
101
101
  getAutocompleteSettings(): AutocompleteSettings;
102
102
  saveAutocompleteSettings(settings: AutocompleteSettings): void;
103
103
  autocomplete(req: AutocompleteRequest): Promise<AutocompleteResponse>;
104
+ /** Generic AI text transform — translate / proofread / summarize.
105
+ * Shares the autocomplete provider config (provider, key, model). Each
106
+ * feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
107
+ * default false. Returns empty text + reason when disabled or on error. */
108
+ aiTransform(req: AiTransformRequest): Promise<AiTransformResponse>;
104
109
  }
105
110
  //# sourceMappingURL=index.d.ts.map
@@ -1005,6 +1005,106 @@ export class MailxService {
1005
1005
  }
1006
1006
  return { suggestion: "" };
1007
1007
  }
1008
+ /** Generic AI text transform — translate / proofread / summarize.
1009
+ * Shares the autocomplete provider config (provider, key, model). Each
1010
+ * feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
1011
+ * default false. Returns empty text + reason when disabled or on error. */
1012
+ async aiTransform(req) {
1013
+ const cfg = loadAutocomplete();
1014
+ if (cfg.provider === "off")
1015
+ return { text: "", reason: "AI provider not configured" };
1016
+ const featureGate = {
1017
+ translate: cfg.translateEnabled,
1018
+ proofread: cfg.proofreadEnabled,
1019
+ summarize: cfg.proofreadEnabled, // bundled with proofread for now
1020
+ };
1021
+ if (!featureGate[req.action])
1022
+ return { text: "", reason: `AI ${req.action} disabled in settings` };
1023
+ const text = (req.text || "").slice(0, 8000); // sanity cap
1024
+ if (!text.trim())
1025
+ return { text: "", reason: "no input" };
1026
+ const target = req.targetLang || "en";
1027
+ let systemPrompt;
1028
+ let userPrompt;
1029
+ switch (req.action) {
1030
+ case "translate":
1031
+ systemPrompt = `You are a translator. Render the user's text into ${target}. Preserve formatting (paragraphs, lists). Output ONLY the translation, no explanation.`;
1032
+ userPrompt = text;
1033
+ break;
1034
+ case "proofread":
1035
+ systemPrompt = `You are an editor. Return the user's text with grammar, spelling, and clarity fixed. Preserve voice and meaning. Output ONLY the corrected text, no explanation.`;
1036
+ userPrompt = text;
1037
+ break;
1038
+ case "summarize":
1039
+ systemPrompt = `You are a summarizer. Render the user's text as a short paragraph (2-4 sentences). Output ONLY the summary.`;
1040
+ userPrompt = text;
1041
+ break;
1042
+ }
1043
+ try {
1044
+ if (cfg.provider === "ollama") {
1045
+ const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
1046
+ method: "POST",
1047
+ headers: { "Content-Type": "application/json" },
1048
+ body: JSON.stringify({
1049
+ model: cfg.ollamaModel,
1050
+ prompt: `${systemPrompt}\n\n${userPrompt}`,
1051
+ stream: false,
1052
+ options: { num_predict: 1024 },
1053
+ }),
1054
+ });
1055
+ if (!res.ok)
1056
+ return { text: "", reason: `ollama ${res.status}` };
1057
+ const data = await res.json();
1058
+ return { text: (data.response || "").trim() };
1059
+ }
1060
+ if (cfg.provider === "claude") {
1061
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
1062
+ method: "POST",
1063
+ headers: {
1064
+ "Content-Type": "application/json",
1065
+ "x-api-key": cfg.cloudApiKey,
1066
+ "anthropic-version": "2023-06-01",
1067
+ },
1068
+ body: JSON.stringify({
1069
+ model: cfg.cloudModel,
1070
+ max_tokens: 2048,
1071
+ system: systemPrompt,
1072
+ messages: [{ role: "user", content: userPrompt }],
1073
+ }),
1074
+ });
1075
+ if (!res.ok)
1076
+ return { text: "", reason: `claude ${res.status}` };
1077
+ const data = await res.json();
1078
+ return { text: (data.content?.[0]?.text || "").trim() };
1079
+ }
1080
+ if (cfg.provider === "openai") {
1081
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
1082
+ method: "POST",
1083
+ headers: {
1084
+ "Content-Type": "application/json",
1085
+ "Authorization": `Bearer ${cfg.cloudApiKey}`,
1086
+ },
1087
+ body: JSON.stringify({
1088
+ model: cfg.cloudModel,
1089
+ max_tokens: 2048,
1090
+ messages: [
1091
+ { role: "system", content: systemPrompt },
1092
+ { role: "user", content: userPrompt },
1093
+ ],
1094
+ }),
1095
+ });
1096
+ if (!res.ok)
1097
+ return { text: "", reason: `openai ${res.status}` };
1098
+ const data = await res.json();
1099
+ return { text: (data.choices?.[0]?.message?.content || "").trim() };
1100
+ }
1101
+ }
1102
+ catch (e) {
1103
+ console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
1104
+ return { text: "", reason: e.message };
1105
+ }
1106
+ return { text: "", reason: "no provider matched" };
1107
+ }
1008
1108
  }
1009
1109
  /** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
1010
1110
  function trimSuggestion(text) {
@@ -137,6 +137,10 @@ async function dispatchAction(svc, action, p) {
137
137
  case "saveAutocompleteSettings":
138
138
  svc.saveAutocompleteSettings(p);
139
139
  return { ok: true };
140
+ // AI transform (translate / proofread / summarize) — gated by per-feature
141
+ // toggles in autocomplete settings, both default false.
142
+ case "aiTransform":
143
+ return svc.aiTransform(p);
140
144
  // Attachments
141
145
  case "getAttachment": {
142
146
  const att = await svc.getAttachment(p.accountId, p.uid, p.attachmentId, p.folderId);
@@ -225,6 +225,12 @@ export interface AutocompleteSettings {
225
225
  cloudModel: string;
226
226
  debounceMs: number;
227
227
  maxTokens: number;
228
+ /** Per-feature opt-in for non-autocomplete AI helpers. All default false
229
+ * per user preference (2026-04-21): AI features should be controlled by
230
+ * a flag, initially OFF in settings. Provider config is shared with
231
+ * autocomplete (provider, cloudApiKey, cloudModel, etc.). */
232
+ translateEnabled?: boolean;
233
+ proofreadEnabled?: boolean;
228
234
  }
229
235
  export interface AutocompleteRequest {
230
236
  subject: string;
@@ -235,6 +241,21 @@ export interface AutocompleteRequest {
235
241
  export interface AutocompleteResponse {
236
242
  suggestion: string;
237
243
  }
244
+ export interface AiTransformRequest {
245
+ /** translate = render in `targetLang`; proofread = corrected version
246
+ * with grammar/spelling fixes; summarize = short paragraph summary. */
247
+ action: "translate" | "proofread" | "summarize";
248
+ text: string;
249
+ /** ISO-639-1 (or BCP-47) language code for translate. Defaults to "en". */
250
+ targetLang?: string;
251
+ }
252
+ export interface AiTransformResponse {
253
+ /** Transformed text. Empty when AI is disabled / provider error / feature
254
+ * not enabled — caller should treat empty as "no result". */
255
+ text: string;
256
+ /** Optional reason for empty result, surfaced to UI status bar. */
257
+ reason?: string;
258
+ }
238
259
  /** Body storage backend interface -- implementations are swappable */
239
260
  export interface MessageStore {
240
261
  putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;