@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 +14 -2
- package/bin/mailx.js +56 -1
- package/client/app.js +100 -14
- package/client/components/folder-picker.js +119 -0
- package/client/components/message-list.js +23 -1
- package/client/components/message-viewer.js +64 -4
- package/client/compose/compose.js +97 -20
- package/client/compose/editor.js +51 -7
- package/client/index.html +18 -0
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/styles/layout.css +64 -14
- package/client/styles/variables.css +1 -0
- package/package.json +7 -7
- package/packages/mailx-host/index.d.ts +1 -0
- package/packages/mailx-host/index.js +1 -0
- package/packages/mailx-host/package.json +4 -1
- package/packages/mailx-imap/index.js +66 -0
- package/packages/mailx-service/index.d.ts +6 -1
- package/packages/mailx-service/index.js +146 -11
- package/packages/mailx-service/jsonrpc.js +4 -0
- package/packages/mailx-types/index.d.ts +21 -0
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
|
|
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
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
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(
|
|
447
|
-
console.log(`[compose]
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2000
|
+
function persistAi(mutator) {
|
|
1918
2001
|
getAutocompleteSettings().then((ac) => {
|
|
1919
|
-
ac
|
|
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
|
|
131
|
-
if (!
|
|
187
|
+
const s = doc.defaultView?.getSelection();
|
|
188
|
+
if (!s)
|
|
132
189
|
return;
|
|
133
190
|
const range = doc.createRange();
|
|
134
191
|
range.selectNodeContents(doc.body);
|
|
135
|
-
|
|
136
|
-
|
|
192
|
+
s.removeAllRanges();
|
|
193
|
+
s.addRange(range);
|
|
137
194
|
} },
|
|
138
195
|
{ label: "", action: () => { }, separator: true },
|
|
196
|
+
{ label: selectedText ? "Translate selection" : "Translate message",
|
|
197
|
+
action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) },
|
|
198
|
+
{ label: "", action: () => { }, separator: true },
|
|
139
199
|
{ label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
|
|
140
200
|
{ label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
|
|
141
201
|
{ label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
|