@bobfrankston/mailx 1.0.442 → 1.0.444
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 +4 -0
- package/bin/lean-accounts.js +172 -0
- package/bin/mailx.js +52 -2
- package/client/app.js +20 -13
- package/client/components/message-viewer.js +11 -0
- package/client/compose/compose.js +54 -2
- package/package.json +1 -1
- package/packages/mailx-service/index.js +15 -3
package/README.md
CHANGED
|
@@ -258,6 +258,10 @@ mailx -repair Re-sync message metadata (fix garbled subjects)
|
|
|
258
258
|
mailx -rebuild Wipe local cache and re-download everything from IMAP
|
|
259
259
|
mailx -setup Interactive first-time setup (CLI)
|
|
260
260
|
mailx -test Test IMAP/SMTP connectivity for all accounts
|
|
261
|
+
mailx -reauth Clear cached OAuth tokens; next start re-consents
|
|
262
|
+
mailx -lean-accounts Strip defaulted fields from accounts.jsonc on
|
|
263
|
+
Google Drive (port, tls, auth, enabled, ...).
|
|
264
|
+
Add --dry-run to preview without writing.
|
|
261
265
|
mailx -v Show version
|
|
262
266
|
```
|
|
263
267
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lean-accounts.js — strip default-valued fields from an accounts.jsonc.
|
|
4
|
+
*
|
|
5
|
+
* Reads any accounts.jsonc (the one mailx writes when it bloats it with
|
|
6
|
+
* port: 993, tls: true, auth: "password", enabled: true, sig.html: false,
|
|
7
|
+
* etc.) and emits a compact form. Same logic as the Lean button in the
|
|
8
|
+
* config editor, but standalone so it works without mailx running.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node lean-accounts.js <path> prints lean output to stdout
|
|
12
|
+
* node lean-accounts.js <path> --inplace overwrites the file
|
|
13
|
+
*
|
|
14
|
+
* Drops:
|
|
15
|
+
* - imap/smtp host/port/tls/auth that match the email's provider defaults
|
|
16
|
+
* - imap/smtp user when it equals the email
|
|
17
|
+
* - enabled: true (default)
|
|
18
|
+
* - sig.html: false (default)
|
|
19
|
+
* - per-account name when it matches the file-level "name"
|
|
20
|
+
*
|
|
21
|
+
* Promotes a shared "name" to file-level when every account has the same.
|
|
22
|
+
*
|
|
23
|
+
* Requires: jsonc-parser (any version that exports `parse` with comment+
|
|
24
|
+
* trailing-comma tolerance). The mailx repo's node_modules has it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
// ── Provider defaults: same set mailx-settings uses ────────────────────────
|
|
34
|
+
const PROVIDERS = {
|
|
35
|
+
"gmail.com": {
|
|
36
|
+
label: "Gmail",
|
|
37
|
+
imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
|
|
38
|
+
smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
|
|
39
|
+
},
|
|
40
|
+
"googlemail.com": {
|
|
41
|
+
label: "Gmail",
|
|
42
|
+
imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
|
|
43
|
+
smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
|
|
44
|
+
},
|
|
45
|
+
"outlook.com": {
|
|
46
|
+
label: "Outlook",
|
|
47
|
+
imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
|
|
48
|
+
smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
|
|
49
|
+
},
|
|
50
|
+
"hotmail.com": {
|
|
51
|
+
label: "Outlook",
|
|
52
|
+
imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
|
|
53
|
+
smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
|
|
54
|
+
},
|
|
55
|
+
"yahoo.com": {
|
|
56
|
+
label: "Yahoo",
|
|
57
|
+
imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "oauth2" },
|
|
58
|
+
smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "oauth2" },
|
|
59
|
+
},
|
|
60
|
+
"icloud.com": {
|
|
61
|
+
label: "iCloud",
|
|
62
|
+
imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "oauth2" },
|
|
63
|
+
smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "oauth2" },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function denormalizeAccount(acct, globalName) {
|
|
68
|
+
const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
|
|
69
|
+
const provider = PROVIDERS[domain];
|
|
70
|
+
const out = {};
|
|
71
|
+
out.id = acct.id;
|
|
72
|
+
if (acct.label && acct.label !== provider?.label && acct.label !== acct.id) out.label = acct.label;
|
|
73
|
+
out.email = acct.email;
|
|
74
|
+
if (acct.name && acct.name !== globalName) out.name = acct.name;
|
|
75
|
+
if (acct.primary) out.primary = true;
|
|
76
|
+
if (acct.primaryCalendar !== undefined) out.primaryCalendar = acct.primaryCalendar;
|
|
77
|
+
if (acct.primaryTasks !== undefined) out.primaryTasks = acct.primaryTasks;
|
|
78
|
+
if (acct.primaryContacts !== undefined) out.primaryContacts = acct.primaryContacts;
|
|
79
|
+
|
|
80
|
+
const imapOut = {};
|
|
81
|
+
if (acct.imap?.host && acct.imap.host !== provider?.imap.host) imapOut.host = acct.imap.host;
|
|
82
|
+
if (acct.imap?.user && acct.imap.user !== acct.email) imapOut.user = acct.imap.user;
|
|
83
|
+
if (acct.imap?.password) imapOut.password = acct.imap.password;
|
|
84
|
+
if (acct.imap?.port && acct.imap.port !== (provider?.imap.port ?? 993)) imapOut.port = acct.imap.port;
|
|
85
|
+
if (acct.imap?.tls !== undefined && acct.imap.tls !== (provider?.imap.tls ?? true)) imapOut.tls = acct.imap.tls;
|
|
86
|
+
if (acct.imap?.auth && acct.imap.auth !== (provider?.imap.auth ?? "password")) imapOut.auth = acct.imap.auth;
|
|
87
|
+
if (Object.keys(imapOut).length > 0) out.imap = imapOut;
|
|
88
|
+
|
|
89
|
+
const smtpOut = {};
|
|
90
|
+
if (acct.smtp?.host && acct.smtp.host !== provider?.smtp.host) smtpOut.host = acct.smtp.host;
|
|
91
|
+
if (acct.smtp?.user && acct.smtp.user !== acct.email && acct.smtp.user !== acct.imap?.user) smtpOut.user = acct.smtp.user;
|
|
92
|
+
if (acct.smtp?.password) smtpOut.password = acct.smtp.password;
|
|
93
|
+
if (acct.smtp?.port && acct.smtp.port !== (provider?.smtp.port ?? 587)) smtpOut.port = acct.smtp.port;
|
|
94
|
+
if (acct.smtp?.tls !== undefined && acct.smtp.tls !== (provider?.smtp.tls ?? true)) smtpOut.tls = acct.smtp.tls;
|
|
95
|
+
if (acct.smtp?.auth && acct.smtp.auth !== (provider?.smtp.auth ?? "password")) smtpOut.auth = acct.smtp.auth;
|
|
96
|
+
if (Object.keys(smtpOut).length > 0) out.smtp = smtpOut;
|
|
97
|
+
|
|
98
|
+
if (acct.defaultSend) out.defaultSend = true;
|
|
99
|
+
if (acct.enabled === false) out.enabled = false;
|
|
100
|
+
if (acct.relayDomains?.length > 0) out.relayDomains = acct.relayDomains;
|
|
101
|
+
if (acct.deliveredToPrefix?.length > 0) out.deliveredToPrefix = acct.deliveredToPrefix;
|
|
102
|
+
if (acct.identityDomains?.length > 0) out.identityDomains = acct.identityDomains;
|
|
103
|
+
|
|
104
|
+
const syncContactsDefault = provider?.imap.auth === "oauth2";
|
|
105
|
+
if (acct.syncContacts !== undefined && acct.syncContacts !== syncContactsDefault) {
|
|
106
|
+
out.syncContacts = acct.syncContacts;
|
|
107
|
+
}
|
|
108
|
+
if (acct.signature) out.signature = acct.signature;
|
|
109
|
+
if (acct.sig?.text) {
|
|
110
|
+
out.sig = acct.sig.html ? { text: acct.sig.text, html: true } : { text: acct.sig.text };
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function main() {
|
|
116
|
+
const args = process.argv.slice(2);
|
|
117
|
+
const inputPath = args.find(a => !a.startsWith("--"));
|
|
118
|
+
const inplace = args.includes("--inplace");
|
|
119
|
+
if (!inputPath) {
|
|
120
|
+
console.error("Usage: node lean-accounts.js <path-to-accounts.jsonc> [--inplace]");
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
if (!fs.existsSync(inputPath)) {
|
|
124
|
+
console.error(`File not found: ${inputPath}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Resolve jsonc-parser from the local mailx repo's node_modules so the
|
|
129
|
+
// script doesn't require a global install. The repo root is two levels
|
|
130
|
+
// up from bin/ — and we walk up further if invoked from elsewhere.
|
|
131
|
+
let parseJsonc;
|
|
132
|
+
for (const dir of [
|
|
133
|
+
path.join(__dirname, "..", "node_modules", "jsonc-parser"),
|
|
134
|
+
path.join(__dirname, "..", "..", "node_modules", "jsonc-parser"),
|
|
135
|
+
path.join(process.cwd(), "node_modules", "jsonc-parser"),
|
|
136
|
+
]) {
|
|
137
|
+
if (fs.existsSync(path.join(dir, "package.json"))) {
|
|
138
|
+
const mod = await import(pathToFileURL(path.join(dir, "lib", "esm", "main.js")).href).catch(() => null) ||
|
|
139
|
+
await import("jsonc-parser").catch(() => null);
|
|
140
|
+
if (mod) { parseJsonc = mod.parse; break; }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!parseJsonc) {
|
|
144
|
+
try { parseJsonc = (await import("jsonc-parser")).parse; }
|
|
145
|
+
catch { console.error("jsonc-parser not found. Install or run from inside the mailx repo."); process.exit(1); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const raw = fs.readFileSync(inputPath, "utf-8");
|
|
149
|
+
const errors = [];
|
|
150
|
+
const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
|
|
151
|
+
if (errors.length) {
|
|
152
|
+
console.error(`JSONC parse error: ${errors.map(e => JSON.stringify(e)).join(", ")}`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const accounts = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
|
|
157
|
+
// Promote shared name to file-level when every entry has the same.
|
|
158
|
+
const names = new Set(accounts.map(a => a.name).filter(Boolean));
|
|
159
|
+
const sharedName = names.size === 1 ? [...names][0] : cfg?.name;
|
|
160
|
+
const lean = accounts.map(a => denormalizeAccount(a, sharedName));
|
|
161
|
+
const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
|
|
162
|
+
const output = JSON.stringify(payload, null, 2) + "\n";
|
|
163
|
+
|
|
164
|
+
if (inplace) {
|
|
165
|
+
fs.writeFileSync(inputPath, output);
|
|
166
|
+
console.error(`Wrote lean ${inputPath}`);
|
|
167
|
+
} else {
|
|
168
|
+
process.stdout.write(output);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main().catch(e => { console.error(e); process.exit(1); });
|
package/bin/mailx.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* mailx -repair Re-sync metadata (fix corrupt subjects) keeping .eml files
|
|
18
18
|
* mailx -reauth Clear cached OAuth tokens; next start re-consents
|
|
19
19
|
* (use when new Google scopes have been added)
|
|
20
|
+
* mailx -lean-accounts Strip defaulted fields from the GDrive copy of
|
|
21
|
+
* accounts.jsonc (port, tls, auth, enabled, ...).
|
|
22
|
+
* Add --dry-run to preview without writing.
|
|
20
23
|
*/
|
|
21
24
|
import fs from "node:fs";
|
|
22
25
|
import path from "node:path";
|
|
@@ -89,7 +92,7 @@ function pidAlive(pid) {
|
|
|
89
92
|
// on an old UI with no indication that the install has been upgraded.
|
|
90
93
|
// Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
|
|
91
94
|
// the internal --daemon respawn.
|
|
92
|
-
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
|
|
95
|
+
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth", "lean-accounts"];
|
|
93
96
|
const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
|
|
94
97
|
if (!isDaemon && !__isCommandInvocation) {
|
|
95
98
|
const inst = readInstanceFile();
|
|
@@ -147,8 +150,9 @@ const testMode = hasFlag("test");
|
|
|
147
150
|
const rebuildMode = hasFlag("rebuild");
|
|
148
151
|
const repairMode = hasFlag("repair");
|
|
149
152
|
const importMode = hasFlag("import");
|
|
153
|
+
const leanAccountsMode = hasFlag("lean-accounts");
|
|
150
154
|
// Validate arguments
|
|
151
|
-
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon"];
|
|
155
|
+
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "lean-accounts", "email", "mail", "daemon"];
|
|
152
156
|
for (const arg of args) {
|
|
153
157
|
const flag = arg.replace(/^--?/, "");
|
|
154
158
|
if (arg.startsWith("-") && !knownFlags.includes(flag)) {
|
|
@@ -363,6 +367,52 @@ if (repairMode) {
|
|
|
363
367
|
console.log(" Run 'mailx' to re-sync from IMAP with correct encoding.");
|
|
364
368
|
process.exit(0);
|
|
365
369
|
}
|
|
370
|
+
// Strip default-valued fields from the GDrive copy of accounts.jsonc.
|
|
371
|
+
// Operates on the cloud version directly — the local ~/.mailx/accounts.jsonc
|
|
372
|
+
// is just a cache and gets updated on the next mailx run anyway.
|
|
373
|
+
if (leanAccountsMode) {
|
|
374
|
+
const { parse: parseJsonc } = await import("jsonc-parser");
|
|
375
|
+
const { initCloudConfig, normalizeAccount, denormalizeAccount } = await import("@bobfrankston/mailx-settings");
|
|
376
|
+
const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
|
|
377
|
+
await initCloudConfig("gdrive");
|
|
378
|
+
const raw = await cloudRead("accounts.jsonc");
|
|
379
|
+
if (!raw) {
|
|
380
|
+
console.error("No accounts.jsonc found in GDrive.");
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
const errors = [];
|
|
384
|
+
const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
|
|
385
|
+
if (errors.length) {
|
|
386
|
+
console.error(`JSONC parse error: ${errors.map((e) => JSON.stringify(e)).join(", ")}`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
const accountsRaw = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
|
|
390
|
+
if (accountsRaw.length === 0) {
|
|
391
|
+
console.error("No accounts found in GDrive accounts.jsonc.");
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
const globalName = cfg?.name;
|
|
395
|
+
const normalized = accountsRaw.map(a => normalizeAccount(a, globalName));
|
|
396
|
+
const names = new Set(normalized.map((a) => a.name).filter(Boolean));
|
|
397
|
+
const sharedName = names.size === 1 ? [...names][0] : globalName;
|
|
398
|
+
const lean = normalized.map((a) => denormalizeAccount(a, sharedName));
|
|
399
|
+
const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
|
|
400
|
+
const output = JSON.stringify(payload, null, 2);
|
|
401
|
+
const before = raw.length;
|
|
402
|
+
const after = output.length;
|
|
403
|
+
console.log(`Read ${accountsRaw.length} account(s) from GDrive.`);
|
|
404
|
+
console.log(`Before: ${before} bytes`);
|
|
405
|
+
console.log(`After: ${after} bytes (${Math.round(100 * (1 - after / before))}% smaller)`);
|
|
406
|
+
if (process.argv.includes("--dry-run") || process.argv.includes("-n")) {
|
|
407
|
+
console.log("--- Lean output (dry run, not written) ---");
|
|
408
|
+
console.log(output);
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
await cloudWrite("accounts.jsonc", output);
|
|
412
|
+
console.log("Wrote lean accounts.jsonc back to GDrive.");
|
|
413
|
+
}
|
|
414
|
+
process.exit(0);
|
|
415
|
+
}
|
|
366
416
|
// Import accounts from a local file into GDrive
|
|
367
417
|
if (importMode) {
|
|
368
418
|
const importPath = args.find(a => !a.startsWith("-"));
|
package/client/app.js
CHANGED
|
@@ -696,9 +696,17 @@ async function openCompose(mode) {
|
|
|
696
696
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
697
697
|
// Inline compose: load compose.html in an overlay iframe (same origin, same IPC)
|
|
698
698
|
// Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
|
|
699
|
-
|
|
699
|
+
// Title reflects mode + subject so the user can see what they're replying to
|
|
700
|
+
// ("Re: Stars of STEM 2026" instead of just "Compose"). Forward shows the
|
|
701
|
+
// forward target subject; new compose stays generic.
|
|
702
|
+
const titlePrefix = mode === "reply" ? "Reply" :
|
|
703
|
+
mode === "replyAll" ? "Reply All" :
|
|
704
|
+
mode === "forward" ? "Forward" :
|
|
705
|
+
"Compose";
|
|
706
|
+
const titleSubject = mode === "new" ? "" : (msg?.subject || init.subject || "");
|
|
707
|
+
showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
|
|
700
708
|
}
|
|
701
|
-
function showComposeOverlay() {
|
|
709
|
+
function showComposeOverlay(title = "Compose") {
|
|
702
710
|
const wrapper = document.createElement("div");
|
|
703
711
|
wrapper.className = "compose-overlay";
|
|
704
712
|
// Full-screen on small/short screens, floating on larger
|
|
@@ -712,7 +720,7 @@ function showComposeOverlay() {
|
|
|
712
720
|
// Title bar — drag to move, close button
|
|
713
721
|
const titleBar = document.createElement("div");
|
|
714
722
|
titleBar.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;";
|
|
715
|
-
titleBar.textContent =
|
|
723
|
+
titleBar.textContent = title;
|
|
716
724
|
const closeBtn = document.createElement("button");
|
|
717
725
|
closeBtn.textContent = "✕";
|
|
718
726
|
closeBtn.title = "Save draft and close";
|
|
@@ -1916,23 +1924,22 @@ document.addEventListener("keydown", (e) => {
|
|
|
1916
1924
|
e.preventDefault();
|
|
1917
1925
|
openCompose("new");
|
|
1918
1926
|
}
|
|
1919
|
-
// Ctrl+
|
|
1920
|
-
if (e.ctrlKey && e.key === "
|
|
1921
|
-
e.preventDefault();
|
|
1922
|
-
openCompose("forward");
|
|
1923
|
-
}
|
|
1924
|
-
// Ctrl+R = Reply
|
|
1925
|
-
if (e.ctrlKey && e.key === "r" && !e.shiftKey) {
|
|
1927
|
+
// Ctrl+R = Reply (without Shift)
|
|
1928
|
+
if (e.ctrlKey && e.key === "r" && !e.shiftKey && !e.altKey && !e.metaKey) {
|
|
1926
1929
|
e.preventDefault();
|
|
1927
1930
|
openCompose("reply");
|
|
1928
1931
|
}
|
|
1929
1932
|
// Ctrl+Shift+R = Reply All
|
|
1930
|
-
if (e.ctrlKey && e.shiftKey && e.key === "R") {
|
|
1933
|
+
if (e.ctrlKey && e.shiftKey && e.key === "R" && !e.altKey && !e.metaKey) {
|
|
1931
1934
|
e.preventDefault();
|
|
1932
1935
|
openCompose("replyAll");
|
|
1933
1936
|
}
|
|
1934
|
-
// Ctrl+F = Forward
|
|
1935
|
-
|
|
1937
|
+
// Ctrl+F = Forward (without Shift). Use toLowerCase so a Caps-Lock or
|
|
1938
|
+
// shifted state doesn't bypass us. Single handler — the previous
|
|
1939
|
+
// duplicate fired openCompose twice, which double-loaded the compose
|
|
1940
|
+
// iframe and the second copy got an empty sessionStorage (the first
|
|
1941
|
+
// had already consumed it), producing an empty Forward form.
|
|
1942
|
+
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === "f" || e.key === "F")) {
|
|
1936
1943
|
e.preventDefault();
|
|
1937
1944
|
openCompose("forward");
|
|
1938
1945
|
}
|
|
@@ -1113,6 +1113,17 @@ ${csp}
|
|
|
1113
1113
|
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
1114
1114
|
// Zoom keys handled by parent-side installPreviewControls; don't double-send.
|
|
1115
1115
|
if (e.ctrlKey && (e.key === "=" || e.key === "+" || e.key === "-" || e.key === "0")) return;
|
|
1116
|
+
// Preventing the iframe's default for keys we forward to the parent
|
|
1117
|
+
// is essential — the parent's preventDefault on the synthetic
|
|
1118
|
+
// keydown can't suppress the browser's reaction to the ORIGINAL
|
|
1119
|
+
// event (Ctrl+R reload, Ctrl+F find, etc.). Suppress here so the
|
|
1120
|
+
// browser doesn't act before the parent processes the action.
|
|
1121
|
+
var k = (e.key || "").toLowerCase();
|
|
1122
|
+
var isShortcut = e.ctrlKey && !e.altKey && !e.metaKey && (
|
|
1123
|
+
k === "r" || k === "f" || k === "n" || k === "a" || k === "d" ||
|
|
1124
|
+
k === "z" || k === "y" || k === "k"
|
|
1125
|
+
);
|
|
1126
|
+
if (isShortcut) e.preventDefault();
|
|
1116
1127
|
window.parent.postMessage({
|
|
1117
1128
|
type: "previewKey",
|
|
1118
1129
|
key: e.key, code: e.code,
|
|
@@ -92,10 +92,62 @@ catch { /* private-mode / SecurityError — default quill */ }
|
|
|
92
92
|
}
|
|
93
93
|
catch { /* non-fatal */ }
|
|
94
94
|
})();
|
|
95
|
-
|
|
95
|
+
// Whatever happens in editor init, surface failures to the mailx log
|
|
96
|
+
// AND fall through to a plain-contenteditable fallback so the rest of
|
|
97
|
+
// the compose script (Discard, X, Send, save-draft) still runs. Earlier
|
|
98
|
+
// versions re-threw asset-load failures, which left compose with dead
|
|
99
|
+
// buttons and the user with no recourse — exactly the "Reply window
|
|
100
|
+
// won't close" symptom we hit when Quill's CDN was unreachable.
|
|
101
|
+
let editor;
|
|
102
|
+
let editorAssetError = null;
|
|
103
|
+
try {
|
|
104
|
+
await loadEditorAssets(editorType);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
editorAssetError = e;
|
|
108
|
+
logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
|
|
109
|
+
}
|
|
96
110
|
const container = document.getElementById("compose-editor");
|
|
97
111
|
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
98
|
-
|
|
112
|
+
try {
|
|
113
|
+
if (editorAssetError)
|
|
114
|
+
throw editorAssetError;
|
|
115
|
+
editor = await createEditor(container, editorType);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
logClientEvent("compose-editor-create-failed", { type: editorType, error: String(e?.message || e) });
|
|
119
|
+
// Render a minimal contenteditable fallback so the user can still type
|
|
120
|
+
// SOMETHING. Without this, an editor failure leaves the compose form
|
|
121
|
+
// half-functional (To/Cc/Bcc work, body doesn't) and the user doesn't
|
|
122
|
+
// know why. The fallback is a plain div — no toolbar, no rich text.
|
|
123
|
+
container.innerHTML = `<div class="compose-fallback-editor" contenteditable="true" style="border:1px solid #c00;padding:8px;min-height:200px;background:#fff" data-fallback="true"></div>`;
|
|
124
|
+
const fallback = container.querySelector(".compose-fallback-editor");
|
|
125
|
+
editor = {
|
|
126
|
+
root: fallback,
|
|
127
|
+
setHtml: (html) => { fallback.innerHTML = html; },
|
|
128
|
+
getHtml: () => fallback.innerHTML,
|
|
129
|
+
getText: () => fallback.innerText,
|
|
130
|
+
focus: () => fallback.focus(),
|
|
131
|
+
setCursor: () => { },
|
|
132
|
+
getScrollContainer: () => fallback,
|
|
133
|
+
onContentChange: (handler) => { fallback.addEventListener("input", handler); },
|
|
134
|
+
onKeyDown: (handler) => { fallback.addEventListener("keydown", handler); },
|
|
135
|
+
insertTextAtCursor: (text) => {
|
|
136
|
+
const sel = window.getSelection();
|
|
137
|
+
if (sel && sel.rangeCount > 0) {
|
|
138
|
+
const range = sel.getRangeAt(0);
|
|
139
|
+
range.deleteContents();
|
|
140
|
+
range.insertNode(document.createTextNode(text));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
fallback.append(document.createTextNode(text));
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
// Surface the failure to the user in the status bar so they know
|
|
148
|
+
// why the toolbar is missing and the editor is plain.
|
|
149
|
+
setTimeout(() => showDraftStatus(`Editor failed to load (${editorType}). Plain-text fallback in use. Open log for details.`, true), 0);
|
|
150
|
+
}
|
|
99
151
|
// Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
|
|
100
152
|
// Persists per-session in localStorage so zoom survives window pop/close cycles.
|
|
101
153
|
(() => {
|
package/package.json
CHANGED
|
@@ -559,11 +559,23 @@ export class MailxService {
|
|
|
559
559
|
// in PATH) fall back to the OS default handler so the user still gets
|
|
560
560
|
// *some* editor. Report which one ran so the UI can say "Opening in
|
|
561
561
|
// Word…" vs "Opening in default editor…".
|
|
562
|
-
|
|
562
|
+
//
|
|
563
|
+
// CRITICAL: must use async spawn (not spawnSync). spawnSync blocks
|
|
564
|
+
// the Node event loop until the spawned process exits — and on
|
|
565
|
+
// Windows, `cmd /c start ... <gui-app>` sometimes does not return
|
|
566
|
+
// immediately when the GUI app hangs around. That froze the entire
|
|
567
|
+
// mailx IPC bridge on Edit-in-Word click; subsequent clicks
|
|
568
|
+
// (Discard, X, anything) hung waiting for a response that never
|
|
569
|
+
// came back. Async spawn launches and returns immediately;
|
|
570
|
+
// success/failure of the GUI launch is invisible from here, but
|
|
571
|
+
// the file is written and the watcher is armed regardless.
|
|
572
|
+
const { spawn } = await import("node:child_process");
|
|
563
573
|
const tryLaunch = (cmd, args) => {
|
|
564
574
|
try {
|
|
565
|
-
const
|
|
566
|
-
|
|
575
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
576
|
+
child.on("error", () => { });
|
|
577
|
+
child.unref();
|
|
578
|
+
return true;
|
|
567
579
|
}
|
|
568
580
|
catch {
|
|
569
581
|
return false;
|