@bobfrankston/mailx 1.0.443 → 1.0.445
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 +1 -0
- package/bin/lean-accounts.js +172 -0
- package/client/app.js +10 -44
- package/client/components/message-viewer.js +11 -0
- package/client/compose/compose.js +10 -7
- package/client/lib/api-client.js +0 -3
- package/client/lib/mailxapi.js +0 -3
- package/package.json +3 -3
- package/packages/mailx-service/index.d.ts +0 -10
- package/packages/mailx-service/index.js +1 -25
- package/packages/mailx-service/jsonrpc.js +0 -2
- package/packages/mailx-store-web/android-bootstrap.js +43 -15
- package/packages/mailx-store-web/web-service.d.ts +1 -1
- package/packages/mailx-store-web/web-service.js +6 -1
package/README.md
CHANGED
|
@@ -258,6 +258,7 @@ 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
|
|
261
262
|
mailx -v Show version
|
|
262
263
|
```
|
|
263
264
|
|
|
@@ -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/client/app.js
CHANGED
|
@@ -1924,23 +1924,22 @@ document.addEventListener("keydown", (e) => {
|
|
|
1924
1924
|
e.preventDefault();
|
|
1925
1925
|
openCompose("new");
|
|
1926
1926
|
}
|
|
1927
|
-
// Ctrl+
|
|
1928
|
-
if (e.ctrlKey && e.key === "
|
|
1929
|
-
e.preventDefault();
|
|
1930
|
-
openCompose("forward");
|
|
1931
|
-
}
|
|
1932
|
-
// Ctrl+R = Reply
|
|
1933
|
-
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) {
|
|
1934
1929
|
e.preventDefault();
|
|
1935
1930
|
openCompose("reply");
|
|
1936
1931
|
}
|
|
1937
1932
|
// Ctrl+Shift+R = Reply All
|
|
1938
|
-
if (e.ctrlKey && e.shiftKey && e.key === "R") {
|
|
1933
|
+
if (e.ctrlKey && e.shiftKey && e.key === "R" && !e.altKey && !e.metaKey) {
|
|
1939
1934
|
e.preventDefault();
|
|
1940
1935
|
openCompose("replyAll");
|
|
1941
1936
|
}
|
|
1942
|
-
// Ctrl+F = Forward
|
|
1943
|
-
|
|
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")) {
|
|
1944
1943
|
e.preventDefault();
|
|
1945
1944
|
openCompose("forward");
|
|
1946
1945
|
}
|
|
@@ -2243,7 +2242,7 @@ document.getElementById("btn-open-log")?.addEventListener("click", async () => {
|
|
|
2243
2242
|
}
|
|
2244
2243
|
});
|
|
2245
2244
|
async function openJsoncEditor(initialFile) {
|
|
2246
|
-
const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc
|
|
2245
|
+
const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc } = await import("./lib/api-client.js");
|
|
2247
2246
|
const backdrop = document.createElement("div");
|
|
2248
2247
|
backdrop.className = "mailx-modal-backdrop";
|
|
2249
2248
|
const panel = document.createElement("div");
|
|
@@ -2279,7 +2278,6 @@ async function openJsoncEditor(initialFile) {
|
|
|
2279
2278
|
<div class="mailx-modal-error" id="jsonc-error" hidden></div>
|
|
2280
2279
|
<div class="mailx-modal-buttons">
|
|
2281
2280
|
<button type="button" class="mailx-modal-btn" data-action="format" title="Reformat indentation while preserving comments and trailing commas">Format</button>
|
|
2282
|
-
<button type="button" class="mailx-modal-btn" data-action="lean" title="accounts.jsonc only — strip default-valued fields (port, tls, auth, etc.) so the file stays compact. Comments are dropped (the lean output is regenerated from values, not the original text)">Lean</button>
|
|
2283
2281
|
<span class="mailx-modal-spacer"></span>
|
|
2284
2282
|
<button type="button" class="mailx-modal-btn" data-action="cancel">Cancel</button>
|
|
2285
2283
|
<button type="button" class="mailx-modal-btn mailx-modal-btn-primary" data-action="save">Save</button>
|
|
@@ -2446,38 +2444,6 @@ async function openJsoncEditor(initialFile) {
|
|
|
2446
2444
|
}
|
|
2447
2445
|
return;
|
|
2448
2446
|
}
|
|
2449
|
-
if (action === "lean") {
|
|
2450
|
-
// accounts.jsonc-only: strip defaulted fields and re-emit
|
|
2451
|
-
// a compact form. User reviews it in the editor, then Save
|
|
2452
|
-
// commits. Lean drops comments because the output is
|
|
2453
|
-
// regenerated from canonical values, not the original text.
|
|
2454
|
-
if (fileSelect.value !== "accounts.jsonc") {
|
|
2455
|
-
errorEl.textContent = "Lean is only available for accounts.jsonc.";
|
|
2456
|
-
errorEl.hidden = false;
|
|
2457
|
-
return;
|
|
2458
|
-
}
|
|
2459
|
-
btn.disabled = true;
|
|
2460
|
-
const orig = btn.textContent;
|
|
2461
|
-
btn.textContent = "Leaning…";
|
|
2462
|
-
try {
|
|
2463
|
-
const r = await leanAccountsJsonc(textarea.value);
|
|
2464
|
-
if (r?.content !== undefined) {
|
|
2465
|
-
textarea.value = r.content;
|
|
2466
|
-
renderGutter();
|
|
2467
|
-
scheduleValidate();
|
|
2468
|
-
errorEl.hidden = true;
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
catch (e) {
|
|
2472
|
-
errorEl.textContent = `Lean failed: ${e.message}`;
|
|
2473
|
-
errorEl.hidden = false;
|
|
2474
|
-
}
|
|
2475
|
-
finally {
|
|
2476
|
-
btn.disabled = false;
|
|
2477
|
-
btn.textContent = orig || "Lean";
|
|
2478
|
-
}
|
|
2479
|
-
return;
|
|
2480
|
-
}
|
|
2481
2447
|
if (action === "save") {
|
|
2482
2448
|
// Final sync-check; refuse to save if it doesn't parse
|
|
2483
2449
|
const err = validateJsonc(textarea.value);
|
|
@@ -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,23 +92,26 @@ catch { /* private-mode / SecurityError — default quill */ }
|
|
|
92
92
|
}
|
|
93
93
|
catch { /* non-fatal */ }
|
|
94
94
|
})();
|
|
95
|
-
// Whatever happens in editor init, surface failures to the mailx log
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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
101
|
let editor;
|
|
102
|
+
let editorAssetError = null;
|
|
102
103
|
try {
|
|
103
104
|
await loadEditorAssets(editorType);
|
|
104
105
|
}
|
|
105
106
|
catch (e) {
|
|
107
|
+
editorAssetError = e;
|
|
106
108
|
logClientEvent("compose-editor-assets-failed", { type: editorType, error: String(e?.message || e) });
|
|
107
|
-
throw e;
|
|
108
109
|
}
|
|
109
110
|
const container = document.getElementById("compose-editor");
|
|
110
111
|
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
111
112
|
try {
|
|
113
|
+
if (editorAssetError)
|
|
114
|
+
throw editorAssetError;
|
|
112
115
|
editor = await createEditor(container, editorType);
|
|
113
116
|
}
|
|
114
117
|
catch (e) {
|
package/client/lib/api-client.js
CHANGED
|
@@ -351,9 +351,6 @@ export function readConfigHelp(name) {
|
|
|
351
351
|
export function unsubscribeOneClick(url) {
|
|
352
352
|
return ipc().unsubscribeOneClick?.(url);
|
|
353
353
|
}
|
|
354
|
-
export function leanAccountsJsonc(content) {
|
|
355
|
-
return ipc().leanAccountsJsonc?.(content) ?? Promise.resolve({ content });
|
|
356
|
-
}
|
|
357
354
|
export function openInWord(editId, html) {
|
|
358
355
|
return ipc().openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: "", opener: "none" });
|
|
359
356
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -134,9 +134,6 @@
|
|
|
134
134
|
formatJsonc: function(content) {
|
|
135
135
|
return callNode("formatJsonc", { content: content });
|
|
136
136
|
},
|
|
137
|
-
leanAccountsJsonc: function(content) {
|
|
138
|
-
return callNode("leanAccountsJsonc", { content: content });
|
|
139
|
-
},
|
|
140
137
|
readConfigHelp: function(name) {
|
|
141
138
|
return callNode("readConfigHelp", { name: name });
|
|
142
139
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.445",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@bobfrankston/iflow-node": "^0.1.8",
|
|
37
37
|
"@bobfrankston/miscinfo": "^1.0.10",
|
|
38
38
|
"@bobfrankston/oauthsupport": "^1.0.25",
|
|
39
|
-
"@bobfrankston/msger": "^0.1.
|
|
39
|
+
"@bobfrankston/msger": "^0.1.367",
|
|
40
40
|
"@bobfrankston/mailx-host": "^0.1.8",
|
|
41
41
|
"@capacitor/android": "^8.3.0",
|
|
42
42
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"@bobfrankston/iflow-node": "^0.1.8",
|
|
101
101
|
"@bobfrankston/miscinfo": "^1.0.10",
|
|
102
102
|
"@bobfrankston/oauthsupport": "^1.0.25",
|
|
103
|
-
"@bobfrankston/msger": "^0.1.
|
|
103
|
+
"@bobfrankston/msger": "^0.1.367",
|
|
104
104
|
"@bobfrankston/mailx-host": "^0.1.8",
|
|
105
105
|
"@capacitor/android": "^8.3.0",
|
|
106
106
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -316,16 +316,6 @@ export declare class MailxService {
|
|
|
316
316
|
* `config.jsonc` is the local per-machine config (not cloud-synced). */
|
|
317
317
|
readJsoncFile(name: string): Promise<string | null>;
|
|
318
318
|
formatJsonc(content: string): Promise<string>;
|
|
319
|
-
/** Strip default-valued fields from accounts.jsonc and return the lean
|
|
320
|
-
* form. Called from the JSONC editor's "Lean" button so the user can
|
|
321
|
-
* see the tidy version before saving. Round-trips through normalize→
|
|
322
|
-
* denormalize so the canonicalization rules stay in one place
|
|
323
|
-
* (mailx-settings). Drops port: 993, tls: true, auth: "password",
|
|
324
|
-
* enabled: true, sig.html: false, etc. when they match the defaults
|
|
325
|
-
* for the email's provider. Promotes a shared `name` to the file
|
|
326
|
-
* level when every account has the same name. Only handles
|
|
327
|
-
* accounts.jsonc — other JSONC files have hand-curated shapes. */
|
|
328
|
-
leanAccountsJsonc(content: string): Promise<string>;
|
|
329
319
|
/** Return the help section for a named config file, extracted from docs/config-help.md.
|
|
330
320
|
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
331
321
|
readConfigHelp(name: string): Promise<string>;
|
|
@@ -9,7 +9,7 @@ import * as path from "node:path";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
import * as gsync from "./google-sync.js";
|
|
12
|
-
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir
|
|
12
|
+
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
|
|
13
13
|
import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
|
|
14
14
|
import { simpleParser } from "mailparser";
|
|
15
15
|
/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
|
|
@@ -2053,30 +2053,6 @@ export class MailxService {
|
|
|
2053
2053
|
});
|
|
2054
2054
|
return applyEdits(content, edits);
|
|
2055
2055
|
}
|
|
2056
|
-
/** Strip default-valued fields from accounts.jsonc and return the lean
|
|
2057
|
-
* form. Called from the JSONC editor's "Lean" button so the user can
|
|
2058
|
-
* see the tidy version before saving. Round-trips through normalize→
|
|
2059
|
-
* denormalize so the canonicalization rules stay in one place
|
|
2060
|
-
* (mailx-settings). Drops port: 993, tls: true, auth: "password",
|
|
2061
|
-
* enabled: true, sig.html: false, etc. when they match the defaults
|
|
2062
|
-
* for the email's provider. Promotes a shared `name` to the file
|
|
2063
|
-
* level when every account has the same name. Only handles
|
|
2064
|
-
* accounts.jsonc — other JSONC files have hand-curated shapes. */
|
|
2065
|
-
async leanAccountsJsonc(content) {
|
|
2066
|
-
const { parse: parseJsonc } = await import("jsonc-parser");
|
|
2067
|
-
const errors = [];
|
|
2068
|
-
const cfg = parseJsonc(content, errors, { allowTrailingComma: true });
|
|
2069
|
-
if (errors.length)
|
|
2070
|
-
throw new Error(`JSONC parse error: ${errors.map((e) => e.error).join(", ")}`);
|
|
2071
|
-
const raw = cfg?.accounts || (Array.isArray(cfg) ? cfg : []);
|
|
2072
|
-
const globalName = cfg?.name;
|
|
2073
|
-
const normalized = raw.map((a) => normalizeAccount(a, globalName));
|
|
2074
|
-
const names = new Set(normalized.map((a) => a.name).filter(Boolean));
|
|
2075
|
-
const sharedName = names.size === 1 ? [...names][0] : globalName;
|
|
2076
|
-
const lean = normalized.map((a) => denormalizeAccount(a, sharedName));
|
|
2077
|
-
const payload = sharedName ? { name: sharedName, accounts: lean } : { accounts: lean };
|
|
2078
|
-
return JSON.stringify(payload, null, 2);
|
|
2079
|
-
}
|
|
2080
2056
|
/** Return the help section for a named config file, extracted from docs/config-help.md.
|
|
2081
2057
|
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
2082
2058
|
async readConfigHelp(name) {
|
|
@@ -177,8 +177,6 @@ async function dispatchAction(svc, action, p) {
|
|
|
177
177
|
return await svc.unsubscribeOneClick(p.url);
|
|
178
178
|
case "openInWord":
|
|
179
179
|
return await svc.openInWord(p.editId, p.html);
|
|
180
|
-
case "leanAccountsJsonc":
|
|
181
|
-
return { content: await svc.leanAccountsJsonc(p.content) };
|
|
182
180
|
case "closeWordEdit":
|
|
183
181
|
return await svc.closeWordEdit(p.editId);
|
|
184
182
|
// Client-side tracing — lets webview / iframe code ship events to the
|
|
@@ -533,36 +533,64 @@ class AndroidSyncManager {
|
|
|
533
533
|
}
|
|
534
534
|
}
|
|
535
535
|
}
|
|
536
|
-
|
|
536
|
+
/** In-flight send tracker keyed by queueUid. Prevents
|
|
537
|
+
* processSendQueue from re-firing the same row when it overlaps
|
|
538
|
+
* with an in-progress attempt (e.g., the periodic tick fires while
|
|
539
|
+
* the original attemptSend's promise is still pending). Without
|
|
540
|
+
* this, a slow Gmail/SMTP send race-conditions into a double-send. */
|
|
541
|
+
sendInFlight = new Set();
|
|
542
|
+
async queueOutgoingLocal(accountId, rawMessage) {
|
|
537
543
|
// Local-first: PERSIST to sync_actions before attempting the network
|
|
538
544
|
// send, so a crash / offline / process kill between now and SMTP ACK
|
|
539
545
|
// doesn't drop the message. Desktop parity — PC writes `.ltr` to disk
|
|
540
|
-
//
|
|
541
|
-
// IndexedDB
|
|
542
|
-
//
|
|
543
|
-
//
|
|
546
|
+
// synchronously; Android writes a sync_actions row and now FLUSHES
|
|
547
|
+
// sql.js → IndexedDB before returning. The previous version relied on
|
|
548
|
+
// the 1-second scheduleSave debounce, so a tab-close inside the debounce
|
|
549
|
+
// window erased the row before it was persisted — the "letter just
|
|
550
|
+
// disappeared" symptom user-reported 2026-04-30.
|
|
544
551
|
//
|
|
545
|
-
//
|
|
546
|
-
// be queued." Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr`.
|
|
552
|
+
// Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr` durable write.
|
|
547
553
|
const queueUid = -Date.now();
|
|
548
554
|
this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
|
|
555
|
+
await this.db.flush();
|
|
549
556
|
this.attemptSend(accountId, queueUid, rawMessage);
|
|
550
557
|
}
|
|
551
558
|
/** Kick off a send for a message that's already in the queue. Called by
|
|
552
559
|
* queueOutgoingLocal on a fresh submit AND by processSendQueue on
|
|
553
|
-
* startup / periodic tick for anything stranded from a prior run.
|
|
560
|
+
* startup / periodic tick for anything stranded from a prior run.
|
|
561
|
+
* Guards against double-send via sendInFlight. */
|
|
554
562
|
attemptSend(accountId, queueUid, rawMessage) {
|
|
563
|
+
if (this.sendInFlight.has(queueUid))
|
|
564
|
+
return;
|
|
565
|
+
this.sendInFlight.add(queueUid);
|
|
566
|
+
// Helper to mark complete + flush + clear in-flight — used on every
|
|
567
|
+
// success/failure exit. Flush ensures the row deletion or attempt
|
|
568
|
+
// counter actually reaches IndexedDB before the next process-kill,
|
|
569
|
+
// matching the "persist before network" rule for the post-network
|
|
570
|
+
// outcome too. Without flushing on completion, a successful send
|
|
571
|
+
// followed by a fast app-close left the row in the queue, which
|
|
572
|
+
// looked like a "stuck" message on next launch.
|
|
573
|
+
const finishSend = (success, error) => {
|
|
574
|
+
if (success) {
|
|
575
|
+
this.db.completeSyncActionByUid(accountId, "send", queueUid);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
this.db.failSyncActionByUid(accountId, "send", queueUid, error || "send failed");
|
|
579
|
+
}
|
|
580
|
+
this.db.flush().catch(() => { });
|
|
581
|
+
this.sendInFlight.delete(queueUid);
|
|
582
|
+
};
|
|
555
583
|
const provider = this.getProvider(accountId);
|
|
556
584
|
if (provider && typeof provider.sendRaw === "function") {
|
|
557
585
|
provider.sendRaw(rawMessage)
|
|
558
586
|
.then((result) => {
|
|
559
587
|
console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
|
|
560
|
-
|
|
588
|
+
finishSend(true);
|
|
561
589
|
emitEvent({ type: "sendComplete", accountId, messageId: result.id });
|
|
562
590
|
})
|
|
563
591
|
.catch((e) => {
|
|
564
592
|
console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
|
|
565
|
-
|
|
593
|
+
finishSend(false, e.message || String(e));
|
|
566
594
|
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
567
595
|
});
|
|
568
596
|
return;
|
|
@@ -574,7 +602,7 @@ class AndroidSyncManager {
|
|
|
574
602
|
if (!row) {
|
|
575
603
|
const e = "Unknown account";
|
|
576
604
|
console.error(`[send] ${accountId}: ${e}`);
|
|
577
|
-
|
|
605
|
+
finishSend(false, e);
|
|
578
606
|
emitEvent({ type: "sendError", accountId, error: e });
|
|
579
607
|
return;
|
|
580
608
|
}
|
|
@@ -584,26 +612,26 @@ class AndroidSyncManager {
|
|
|
584
612
|
}
|
|
585
613
|
catch {
|
|
586
614
|
const e = "Account config malformed";
|
|
587
|
-
|
|
615
|
+
finishSend(false, e);
|
|
588
616
|
emitEvent({ type: "sendError", accountId, error: e });
|
|
589
617
|
return;
|
|
590
618
|
}
|
|
591
619
|
if (!account.smtp) {
|
|
592
620
|
const e = "No SMTP config for this account";
|
|
593
621
|
console.error(`[send] ${accountId}: ${e}`);
|
|
594
|
-
|
|
622
|
+
finishSend(false, e);
|
|
595
623
|
emitEvent({ type: "sendError", accountId, error: e });
|
|
596
624
|
return;
|
|
597
625
|
}
|
|
598
626
|
this.sendViaSmtpDirect(accountId, account, rawMessage)
|
|
599
627
|
.then((result) => {
|
|
600
628
|
console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
|
|
601
|
-
|
|
629
|
+
finishSend(true);
|
|
602
630
|
emitEvent({ type: "sendComplete", accountId });
|
|
603
631
|
})
|
|
604
632
|
.catch((e) => {
|
|
605
633
|
console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
|
|
606
|
-
|
|
634
|
+
finishSend(false, e.message || String(e));
|
|
607
635
|
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
608
636
|
});
|
|
609
637
|
}
|
|
@@ -30,7 +30,7 @@ export interface WebSyncManager {
|
|
|
30
30
|
}[], targetFolderId: number): Promise<void>;
|
|
31
31
|
moveMessageCrossAccount(accountId: string, uid: number, folderId: number, targetAccountId: string, targetFolderId: number): Promise<void>;
|
|
32
32
|
undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
|
|
33
|
-
queueOutgoingLocal(accountId: string, rawMessage: string): void
|
|
33
|
+
queueOutgoingLocal(accountId: string, rawMessage: string): void | Promise<void>;
|
|
34
34
|
saveDraft(accountId: string, raw: string, previousDraftUid?: number, draftId?: string): Promise<number | null>;
|
|
35
35
|
deleteDraft(accountId: string, draftUid: number): Promise<void>;
|
|
36
36
|
reauthenticate(accountId: string): Promise<boolean>;
|
|
@@ -394,7 +394,12 @@ export class WebMailxService {
|
|
|
394
394
|
].join("\r\n");
|
|
395
395
|
rawMessage = `${headers}\r\n\r\n${textEncoded}`;
|
|
396
396
|
}
|
|
397
|
-
|
|
397
|
+
// queueOutgoingLocal on the Android bridge is async (it flushes
|
|
398
|
+
// sql.js → IndexedDB before returning so a tab-close in the
|
|
399
|
+
// debounce window can't lose the row). On the web-worker
|
|
400
|
+
// SyncManager it's synchronous and returns void; awaiting an
|
|
401
|
+
// undefined value is benign, so this works for both.
|
|
402
|
+
await this.syncManager.queueOutgoingLocal(account.id, rawMessage);
|
|
398
403
|
for (const addr of msg.to)
|
|
399
404
|
this.db.recordSentAddress(addr.name, addr.address);
|
|
400
405
|
if (msg.cc)
|