@bobfrankston/mailx 1.0.313 → 1.0.324
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/bin/build-icon-ico.js +59 -0
- package/bin/mailx.js +71 -3
- package/client/app.js +100 -14
- package/client/components/message-list.js +19 -5
- package/client/compose/compose.js +78 -19
- package/client/icon.ico +0 -0
- package/client/lib/mailxapi.js +14 -0
- package/client/styles/components.css +5 -2
- package/package.json +4 -4
- package/packages/mailx-imap/index.d.ts +1 -0
- package/packages/mailx-imap/index.js +90 -0
- package/packages/mailx-service/index.js +46 -11
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Wrap client/icon.png into client/icon.ico using the ICO-with-embedded-PNG
|
|
4
|
+
* format (Windows Vista+). No image decoding needed — Windows accepts a PNG
|
|
5
|
+
* bitstream inside an ICONDIR + ICONDIRENTRY prelude, so we can hand-roll the
|
|
6
|
+
* binary without any imaging dependency.
|
|
7
|
+
*
|
|
8
|
+
* Run: node bin/build-icon-ico.js
|
|
9
|
+
*
|
|
10
|
+
* Output: client/icon.ico derived from client/icon.png. Re-run whenever the
|
|
11
|
+
* source PNG changes.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
17
|
+
const src = path.join(root, "client", "icon.png");
|
|
18
|
+
const dst = path.join(root, "client", "icon.ico");
|
|
19
|
+
|
|
20
|
+
if (!fs.existsSync(src)) {
|
|
21
|
+
console.error(`build-icon-ico: source not found: ${src}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const png = fs.readFileSync(src);
|
|
26
|
+
|
|
27
|
+
// Parse PNG IHDR for width / height. PNG magic (8 bytes) + IHDR chunk length
|
|
28
|
+
// (4 bytes) + "IHDR" (4 bytes), then width (4) + height (4) at offsets 16/20.
|
|
29
|
+
if (png.length < 24 || png.toString("ascii", 12, 16) !== "IHDR") {
|
|
30
|
+
console.error("build-icon-ico: source is not a PNG (missing IHDR)");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
let width = png.readUInt32BE(16);
|
|
34
|
+
let height = png.readUInt32BE(20);
|
|
35
|
+
if (width > 256) width = 256;
|
|
36
|
+
if (height > 256) height = 256;
|
|
37
|
+
|
|
38
|
+
// ICONDIR (6 bytes): reserved=0, type=1 (icon), count=1
|
|
39
|
+
// ICONDIRENTRY (16 bytes per image):
|
|
40
|
+
// width (1 byte, 0 = 256), height (1 byte, 0 = 256), palette (0), reserved (0),
|
|
41
|
+
// planes (1), bpp (32), size (4), offset (4)
|
|
42
|
+
const dir = Buffer.alloc(6);
|
|
43
|
+
dir.writeUInt16LE(0, 0);
|
|
44
|
+
dir.writeUInt16LE(1, 2);
|
|
45
|
+
dir.writeUInt16LE(1, 4);
|
|
46
|
+
|
|
47
|
+
const entry = Buffer.alloc(16);
|
|
48
|
+
entry.writeUInt8(width === 256 ? 0 : width, 0);
|
|
49
|
+
entry.writeUInt8(height === 256 ? 0 : height, 1);
|
|
50
|
+
entry.writeUInt8(0, 2); // palette
|
|
51
|
+
entry.writeUInt8(0, 3); // reserved
|
|
52
|
+
entry.writeUInt16LE(1, 4); // color planes
|
|
53
|
+
entry.writeUInt16LE(32, 6); // bpp
|
|
54
|
+
entry.writeUInt32LE(png.length, 8); // image data size
|
|
55
|
+
entry.writeUInt32LE(22, 12); // offset = 6 (dir) + 16 (entry)
|
|
56
|
+
|
|
57
|
+
const ico = Buffer.concat([dir, entry, png]);
|
|
58
|
+
fs.writeFileSync(dst, ico);
|
|
59
|
+
console.log(`build-icon-ico: wrote ${dst} (${ico.length} bytes, ${width}×${height} PNG-embedded)`);
|
package/bin/mailx.js
CHANGED
|
@@ -23,7 +23,15 @@ import net from "node:net";
|
|
|
23
23
|
import { ports } from "@bobfrankston/miscinfo";
|
|
24
24
|
import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
|
|
25
25
|
setAppName("mailx");
|
|
26
|
-
|
|
26
|
+
// Prefer the .ico (Windows Explorer / taskbar-pin shortcut uses the embedded
|
|
27
|
+
// icon resource of the pinned exe, or a Windows icon resource referenced
|
|
28
|
+
// via PKEY_AppUserModel_RelaunchIconResource — PNG can't play either role).
|
|
29
|
+
// Fall back to PNG for the in-window / tao-level icon on non-Windows.
|
|
30
|
+
{
|
|
31
|
+
const icoPath = path.resolve(import.meta.dirname, "..", "client", "icon.ico");
|
|
32
|
+
const pngPath = path.resolve(import.meta.dirname, "..", "client", "icon.png");
|
|
33
|
+
setAppIcon(fs.existsSync(icoPath) ? icoPath : pngPath);
|
|
34
|
+
}
|
|
27
35
|
const PORT = ports.mailx;
|
|
28
36
|
const args = process.argv.slice(2);
|
|
29
37
|
// Normalize: accept both -flag and --flag
|
|
@@ -227,6 +235,24 @@ if (hasFlag("kill")) {
|
|
|
227
235
|
}
|
|
228
236
|
}
|
|
229
237
|
catch { /* */ }
|
|
238
|
+
// Kill orphaned msgernative.exe windows. When the node process dies
|
|
239
|
+
// without cascade-killing its WebView child (old crash, forced
|
|
240
|
+
// taskkill, etc.), the msgernative.exe stays on screen and looks
|
|
241
|
+
// like a live mailx. mailx -kill should leave no trace.
|
|
242
|
+
// Scoped to exes launched for mailx by filtering CommandLine — don't
|
|
243
|
+
// touch msger windows started by other apps (msga, bbs, etc.).
|
|
244
|
+
try {
|
|
245
|
+
const ps = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='msgernative.exe'\\" | Where-Object { $_.CommandLine -match 'mailx' -or $_.Path -match 'mailx' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8" }).trim();
|
|
246
|
+
for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
|
|
247
|
+
try {
|
|
248
|
+
execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
|
|
249
|
+
console.log(`Killed PID ${pid} (mailx msgernative/WebView)`);
|
|
250
|
+
killed++;
|
|
251
|
+
}
|
|
252
|
+
catch { /* */ }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch { /* */ }
|
|
230
256
|
}
|
|
231
257
|
else {
|
|
232
258
|
try {
|
|
@@ -903,12 +929,20 @@ async function main() {
|
|
|
903
929
|
}
|
|
904
930
|
catch { /* no saved geometry — use defaults */ }
|
|
905
931
|
const rootPkgVersion = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version;
|
|
932
|
+
// Prefer .ico over .png for the window icon: on Windows, the .ico
|
|
933
|
+
// path doubles as the PKEY_AppUserModel_RelaunchIconResource value
|
|
934
|
+
// so taskbar pins use mailx's icon instead of msgernative's embedded
|
|
935
|
+
// resource. msger auto-detects the .ico extension and wires the
|
|
936
|
+
// relaunch icon after window creation.
|
|
937
|
+
const __iconIco = path.join(clientDir, "icon.ico");
|
|
938
|
+
const __iconPng = path.join(clientDir, "icon.png");
|
|
939
|
+
const __iconPath = fs.existsSync(__iconIco) ? __iconIco : __iconPng;
|
|
906
940
|
const handle = showService({
|
|
907
941
|
title: `mailx v${rootPkgVersion}`,
|
|
908
942
|
url: "index.html",
|
|
909
943
|
contentDir: clientDir,
|
|
910
944
|
initScript: mailxapiScript,
|
|
911
|
-
icon:
|
|
945
|
+
icon: __iconPath,
|
|
912
946
|
aumid: "com.frankston.mailx",
|
|
913
947
|
size: savedGeometry
|
|
914
948
|
? { width: savedGeometry.width, height: savedGeometry.height }
|
|
@@ -921,8 +955,21 @@ async function main() {
|
|
|
921
955
|
// Register ourselves as the live instance so subsequent `mailx` invocations
|
|
922
956
|
// can detect version-mismatch and upgrade us (see top of file). Clear on
|
|
923
957
|
// any of: SIGINT, SIGTERM, normal exit.
|
|
958
|
+
//
|
|
959
|
+
// Critical: the SIGTERM handler must *close the WebView child process*
|
|
960
|
+
// (handle.close() → kills msgernative.exe) before Node exits. Without
|
|
961
|
+
// this, the auto-upgrade leaves the old WebView orphaned on screen and
|
|
962
|
+
// the user sees an apparently frozen "old mailx" while the new Node is
|
|
963
|
+
// trying to spawn a second one. Cascade-killing the child makes the
|
|
964
|
+
// version-mismatch auto-upgrade actually transparent to the user.
|
|
924
965
|
writeInstanceFile(process.pid);
|
|
925
|
-
const __cleanupInstance = () => {
|
|
966
|
+
const __cleanupInstance = () => {
|
|
967
|
+
clearInstanceFile();
|
|
968
|
+
try {
|
|
969
|
+
handle.close();
|
|
970
|
+
}
|
|
971
|
+
catch { /* already gone */ }
|
|
972
|
+
};
|
|
926
973
|
process.once("exit", __cleanupInstance);
|
|
927
974
|
process.once("SIGINT", () => { __cleanupInstance(); process.exit(0); });
|
|
928
975
|
process.once("SIGTERM", () => { __cleanupInstance(); process.exit(0); });
|
|
@@ -957,6 +1004,27 @@ async function main() {
|
|
|
957
1004
|
handle.send({ _cbid: req._cbid, result: { ok: true } });
|
|
958
1005
|
return;
|
|
959
1006
|
}
|
|
1007
|
+
// Restart the daemon in-place without npm install. Spawn a fresh
|
|
1008
|
+
// detached child running `mailx`, then gracefully shut this process
|
|
1009
|
+
// down. The new daemon's version-mismatch / startup flow (see top
|
|
1010
|
+
// of bin/mailx.ts) will either take over instantly (version same)
|
|
1011
|
+
// or auto-upgrade through the instance-file cascade. Used when the
|
|
1012
|
+
// user edits accounts.jsonc and needs the change to take effect
|
|
1013
|
+
// without a terminal round-trip.
|
|
1014
|
+
if (req._action === "restartDaemon") {
|
|
1015
|
+
handle.send({ _cbid: req._cbid, ok: true, status: "restarting" });
|
|
1016
|
+
try {
|
|
1017
|
+
const { spawn: spawnChild } = await import("child_process");
|
|
1018
|
+
const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true });
|
|
1019
|
+
child.unref();
|
|
1020
|
+
console.log(" [restart] Spawned fresh daemon; shutting down current");
|
|
1021
|
+
}
|
|
1022
|
+
catch (e) {
|
|
1023
|
+
console.error(` [restart] Spawn failed: ${e.message}`);
|
|
1024
|
+
}
|
|
1025
|
+
gracefulShutdown("User requested restart");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
960
1028
|
// Auto-update action: run npm install then restart
|
|
961
1029
|
if (req._action === "performUpdate") {
|
|
962
1030
|
handle.send({ _cbid: req._cbid, ok: true, status: "updating" });
|
package/client/app.js
CHANGED
|
@@ -15,12 +15,16 @@ function updateBadge(count) {
|
|
|
15
15
|
badgeCount = count;
|
|
16
16
|
// Update title
|
|
17
17
|
document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;
|
|
18
|
-
//
|
|
18
|
+
// Generate a single badge bitmap used for both the favicon (visible on
|
|
19
|
+
// browser tabs / mobile homescreen) AND the Windows taskbar overlay
|
|
20
|
+
// icon (visible as a Thunderbird-style corner pill on the taskbar
|
|
21
|
+
// button when running via msger). Rendered once, consumed twice.
|
|
19
22
|
const canvas = document.createElement("canvas");
|
|
20
23
|
canvas.width = 32;
|
|
21
24
|
canvas.height = 32;
|
|
22
25
|
const ctx = canvas.getContext("2d");
|
|
23
|
-
//
|
|
26
|
+
// Base envelope icon (always drawn — so the favicon is a recognizable
|
|
27
|
+
// mailx icon even at 0 count).
|
|
24
28
|
ctx.fillStyle = "#4a7ccc";
|
|
25
29
|
ctx.fillRect(2, 8, 28, 20);
|
|
26
30
|
ctx.fillStyle = "#6a9cec";
|
|
@@ -30,12 +34,11 @@ function updateBadge(count) {
|
|
|
30
34
|
ctx.lineTo(30, 8);
|
|
31
35
|
ctx.fill();
|
|
32
36
|
if (count > 0) {
|
|
33
|
-
// Red badge circle
|
|
37
|
+
// Red badge circle with count
|
|
34
38
|
ctx.fillStyle = "#e33";
|
|
35
39
|
ctx.beginPath();
|
|
36
40
|
ctx.arc(24, 8, 8, 0, Math.PI * 2);
|
|
37
41
|
ctx.fill();
|
|
38
|
-
// Badge number
|
|
39
42
|
ctx.fillStyle = "#fff";
|
|
40
43
|
ctx.font = "bold 11px sans-serif";
|
|
41
44
|
ctx.textAlign = "center";
|
|
@@ -49,7 +52,25 @@ function updateBadge(count) {
|
|
|
49
52
|
link.rel = "icon";
|
|
50
53
|
document.head.appendChild(link);
|
|
51
54
|
}
|
|
52
|
-
|
|
55
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
56
|
+
link.href = dataUrl;
|
|
57
|
+
// Also push to the Windows taskbar overlay via msger's IPC helper —
|
|
58
|
+
// no-op on Linux/Mac. For count=0, render a dedicated "no-overlay"
|
|
59
|
+
// icon that's all-transparent so the base icon shows cleanly.
|
|
60
|
+
try {
|
|
61
|
+
const msgapi = window.msgapi;
|
|
62
|
+
if (msgapi?.setTaskbarOverlay) {
|
|
63
|
+
if (count > 0) {
|
|
64
|
+
// strip "data:image/png;base64," prefix → base64 only
|
|
65
|
+
const b64 = dataUrl.split(",")[1] || "";
|
|
66
|
+
msgapi.setTaskbarOverlay(b64, `${count} unread`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
msgapi.setTaskbarOverlay("", "");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch { /* msgapi unavailable in browser fallback */ }
|
|
53
74
|
}
|
|
54
75
|
async function updateNewMessageCount() {
|
|
55
76
|
try {
|
|
@@ -148,6 +169,48 @@ function hideAlert() {
|
|
|
148
169
|
}
|
|
149
170
|
}
|
|
150
171
|
alertDismiss?.addEventListener("click", hideAlert);
|
|
172
|
+
/** Show the alert banner with a "Restart" button wired to the mailxapi
|
|
173
|
+
* restartDaemon action. Used when a watched config file whose changes
|
|
174
|
+
* don't apply live (accounts.jsonc) has been modified. */
|
|
175
|
+
function showRestartForConfigBanner() {
|
|
176
|
+
if (!alertBanner || !alertText)
|
|
177
|
+
return;
|
|
178
|
+
alertText.textContent = "accounts.jsonc changed — restart to apply.";
|
|
179
|
+
alertBanner.hidden = false;
|
|
180
|
+
alertBanner.dataset.key = "config-restart";
|
|
181
|
+
// Avoid duplicate buttons across repeat changes.
|
|
182
|
+
const existing = alertBanner.querySelector("#alert-restart-btn");
|
|
183
|
+
if (existing)
|
|
184
|
+
return;
|
|
185
|
+
const btn = document.createElement("button");
|
|
186
|
+
btn.id = "alert-restart-btn";
|
|
187
|
+
btn.textContent = "Restart now";
|
|
188
|
+
btn.style.cssText = "margin-left: 12px; padding: 3px 12px; cursor: pointer;";
|
|
189
|
+
btn.addEventListener("click", async () => {
|
|
190
|
+
btn.disabled = true;
|
|
191
|
+
btn.textContent = "Restarting…";
|
|
192
|
+
try {
|
|
193
|
+
const ipc = window.mailxapi;
|
|
194
|
+
if (ipc?.restartDaemon) {
|
|
195
|
+
await ipc.restartDaemon();
|
|
196
|
+
// Service is going down; the WebView should reload shortly
|
|
197
|
+
// when the replacement daemon takes over. Force a reload
|
|
198
|
+
// after a short delay in case the event doesn't arrive.
|
|
199
|
+
setTimeout(() => location.reload(), 2000);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// Non-IPC (server/browser mode) — location reload won't
|
|
203
|
+
// restart the daemon but at least gives the user feedback.
|
|
204
|
+
location.reload();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
btn.textContent = `Failed: ${e?.message || e}`;
|
|
209
|
+
btn.disabled = false;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
alertText.after(btn);
|
|
213
|
+
}
|
|
151
214
|
// ── Wire up components ──
|
|
152
215
|
const folderTree = document.getElementById("folder-tree");
|
|
153
216
|
let currentFolderSpecialUse = "";
|
|
@@ -770,32 +833,46 @@ document.getElementById("btn-flag")?.addEventListener("click", async () => {
|
|
|
770
833
|
}
|
|
771
834
|
});
|
|
772
835
|
async function spamSelectedMessages() {
|
|
836
|
+
console.log("[spam] click — finding selection");
|
|
773
837
|
const selected = getSelectedMessages();
|
|
774
838
|
if (selected.length === 0) {
|
|
775
839
|
const current = getCurrentMessage();
|
|
776
|
-
if (!current)
|
|
840
|
+
if (!current) {
|
|
841
|
+
console.warn("[spam] no message selected and none in viewer — nothing to do");
|
|
842
|
+
alert("No message selected. Click a message first, then the spam button.");
|
|
777
843
|
return;
|
|
844
|
+
}
|
|
778
845
|
selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
|
|
779
846
|
}
|
|
847
|
+
console.log(`[spam] marking ${selected.length} message(s):`, selected);
|
|
780
848
|
const statusSync = document.getElementById("status-sync");
|
|
849
|
+
// Optimistic: remove from list immediately so the user sees action happen.
|
|
850
|
+
// If the IPC fails, put them back. This matches local-first — the server
|
|
851
|
+
// sync is a background detail, the user's action should feel instant.
|
|
852
|
+
const snapshot = [...selected];
|
|
853
|
+
messageState.removeMessages(selected);
|
|
781
854
|
try {
|
|
782
855
|
const byAccount = new Map();
|
|
783
|
-
for (const msg of
|
|
856
|
+
for (const msg of snapshot) {
|
|
784
857
|
const uids = byAccount.get(msg.accountId) || [];
|
|
785
858
|
uids.push(msg.uid);
|
|
786
859
|
byAccount.set(msg.accountId, uids);
|
|
787
860
|
}
|
|
788
861
|
for (const [accountId, uids] of byAccount) {
|
|
789
|
-
await markAsSpamMessages(accountId, uids);
|
|
862
|
+
const result = await markAsSpamMessages(accountId, uids);
|
|
863
|
+
console.log(`[spam] ${accountId}: moved ${result?.moved ?? uids.length} to folderId=${result?.targetFolderId}`);
|
|
790
864
|
}
|
|
791
865
|
if (statusSync)
|
|
792
|
-
statusSync.textContent = `Spam: ${
|
|
793
|
-
messageState.removeMessages(selected);
|
|
866
|
+
statusSync.textContent = `Spam: ${snapshot.length} queued — pending server sync`;
|
|
794
867
|
}
|
|
795
868
|
catch (e) {
|
|
869
|
+
console.error(`[spam] failed:`, e);
|
|
796
870
|
if (statusSync)
|
|
797
|
-
statusSync.textContent = `Spam failed: ${e
|
|
798
|
-
|
|
871
|
+
statusSync.textContent = `Spam failed: ${e?.message || e}`;
|
|
872
|
+
alert(`Mark-as-spam failed: ${e?.message || e}\n\n${selected.length} message(s) stayed in the list; check Settings → account spam folder and accounts.jsonc.`);
|
|
873
|
+
// Best-effort restore: re-set the messages we optimistically removed.
|
|
874
|
+
// removeMessages has no inverse in message-state, so we'll rely on the
|
|
875
|
+
// next folder reload to repopulate. Surface the failure clearly.
|
|
799
876
|
}
|
|
800
877
|
}
|
|
801
878
|
document.getElementById("btn-spam")?.addEventListener("click", spamSelectedMessages);
|
|
@@ -1197,8 +1274,14 @@ onWsEvent((event) => {
|
|
|
1197
1274
|
case "configChanged":
|
|
1198
1275
|
// A watched config file was modified — could be user edit via the
|
|
1199
1276
|
// JSONC editor, a GDrive sync, or mailx itself saving (e.g.
|
|
1200
|
-
// allowlist update on "allow sender").
|
|
1201
|
-
//
|
|
1277
|
+
// allowlist update on "allow sender").
|
|
1278
|
+
//
|
|
1279
|
+
// For accounts.jsonc specifically, surface a sticky banner with a
|
|
1280
|
+
// Restart button — the file change has no effect on the running
|
|
1281
|
+
// daemon (IMAP connections, token caches, sync loops use the old
|
|
1282
|
+
// config snapshot), and users shouldn't need `mailx -kill` just
|
|
1283
|
+
// to apply an edit. For other files (allowlist / clients /
|
|
1284
|
+
// config) the service handles live, a status-bar flash suffices.
|
|
1202
1285
|
if (statusSync) {
|
|
1203
1286
|
statusSync.textContent = `${event.filename} updated`;
|
|
1204
1287
|
setTimeout(() => {
|
|
@@ -1206,6 +1289,9 @@ onWsEvent((event) => {
|
|
|
1206
1289
|
statusSync.textContent = "";
|
|
1207
1290
|
}, 8000);
|
|
1208
1291
|
}
|
|
1292
|
+
if (event.filename && /accounts\.jsonc/i.test(String(event.filename))) {
|
|
1293
|
+
showRestartForConfigBanner();
|
|
1294
|
+
}
|
|
1209
1295
|
break;
|
|
1210
1296
|
case "cloudError":
|
|
1211
1297
|
// Cloud read/write failed (Google Drive auth/network/etc.). Show a
|
|
@@ -71,17 +71,24 @@ export function initMessageList(handler) {
|
|
|
71
71
|
// Infinite scroll
|
|
72
72
|
const body = document.getElementById("ml-body");
|
|
73
73
|
if (body) {
|
|
74
|
-
// Touch scroll vs tap:
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
74
|
+
// Touch scroll vs tap: the WebView occasionally synthesizes a click on
|
|
75
|
+
// touchend even when the user clearly scrolled, which opened a message
|
|
76
|
+
// just from swiping the list. Multi-signal detection so a scroll is
|
|
77
|
+
// reliably classified:
|
|
78
|
+
// 1. touchmove movement ≥ TAP_SLOP — the primary signal
|
|
79
|
+
// 2. actual scrollTop change between touchstart and touchend — always
|
|
80
|
+
// set the flag when the container moved, even if touchmove never
|
|
81
|
+
// fired (some Android builds coalesce events under momentum)
|
|
82
|
+
// 3. longer TAP_SLOP (15px) — fingers are wide; 10px was too twitchy
|
|
78
83
|
let touchStartY = 0;
|
|
79
84
|
let touchStartX = 0;
|
|
80
|
-
|
|
85
|
+
let touchStartScrollTop = 0;
|
|
86
|
+
const TAP_SLOP = 15;
|
|
81
87
|
body.addEventListener("touchstart", (e) => {
|
|
82
88
|
const t = e.touches[0];
|
|
83
89
|
touchStartY = t.clientY;
|
|
84
90
|
touchStartX = t.clientX;
|
|
91
|
+
touchStartScrollTop = body.scrollTop;
|
|
85
92
|
touchWasScroll = false;
|
|
86
93
|
}, { passive: true });
|
|
87
94
|
body.addEventListener("touchmove", (e) => {
|
|
@@ -90,6 +97,13 @@ export function initMessageList(handler) {
|
|
|
90
97
|
touchWasScroll = true;
|
|
91
98
|
}
|
|
92
99
|
}, { passive: true });
|
|
100
|
+
body.addEventListener("touchend", () => {
|
|
101
|
+
// If the container actually scrolled during this touch, the user
|
|
102
|
+
// was scrolling regardless of how small their finger movement was.
|
|
103
|
+
if (body.scrollTop !== touchStartScrollTop) {
|
|
104
|
+
touchWasScroll = true;
|
|
105
|
+
}
|
|
106
|
+
}, { passive: true });
|
|
93
107
|
body.addEventListener("scroll", () => {
|
|
94
108
|
if (loading)
|
|
95
109
|
return;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Receives init data via window.opener.postMessage or URL params.
|
|
5
5
|
*/
|
|
6
6
|
import { createEditor } from "./editor.js";
|
|
7
|
-
import {
|
|
7
|
+
import { getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
|
|
8
8
|
/** Close compose window */
|
|
9
9
|
function closeCompose() {
|
|
10
10
|
window.close();
|
|
@@ -45,15 +45,37 @@ async function loadEditorAssets(type) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
// ── Determine editor type from settings ──
|
|
48
|
+
//
|
|
49
|
+
// Compose must open fast. The previous flow awaited getVersion() then
|
|
50
|
+
// getSettings() sequentially before the editor was even loaded — any
|
|
51
|
+
// service-side stall (busy sync, slow IMAP, hung OAuth refresh) turned
|
|
52
|
+
// "click Reply" into a multi-second / multi-minute wait with a blank
|
|
53
|
+
// compose window. Local-first: read the editor-type preference from a
|
|
54
|
+
// tiny localStorage cache that we update whenever getSettings succeeds
|
|
55
|
+
// in the background. Default to quill on first run / cache miss.
|
|
48
56
|
let editorType = "quill";
|
|
49
57
|
let appSettings = null;
|
|
50
58
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
editorType = "tiptap";
|
|
59
|
+
const cached = localStorage.getItem("mailx-editor-type");
|
|
60
|
+
if (cached === "tiptap" || cached === "quill")
|
|
61
|
+
editorType = cached;
|
|
55
62
|
}
|
|
56
|
-
catch { /* default
|
|
63
|
+
catch { /* private-mode / SecurityError — default quill */ }
|
|
64
|
+
// Refresh the cache asynchronously — doesn't block compose open.
|
|
65
|
+
(async () => {
|
|
66
|
+
try {
|
|
67
|
+
appSettings = await getSettings();
|
|
68
|
+
const next = appSettings?.ui?.editor === "tiptap" ? "tiptap" : "quill";
|
|
69
|
+
try {
|
|
70
|
+
localStorage.setItem("mailx-editor-type", next);
|
|
71
|
+
}
|
|
72
|
+
catch { /* */ }
|
|
73
|
+
// Note: we don't hot-swap the editor if the preference changed while
|
|
74
|
+
// compose was opening — the old type is already instantiated. Next
|
|
75
|
+
// compose open will pick up the new preference.
|
|
76
|
+
}
|
|
77
|
+
catch { /* non-fatal */ }
|
|
78
|
+
})();
|
|
57
79
|
await loadEditorAssets(editorType);
|
|
58
80
|
const container = document.getElementById("compose-editor");
|
|
59
81
|
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
@@ -382,26 +404,63 @@ function scheduleDraftSave() {
|
|
|
382
404
|
clearTimeout(draftDebounceTimer);
|
|
383
405
|
draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
|
|
384
406
|
}
|
|
385
|
-
// ── Initialize:
|
|
386
|
-
//
|
|
387
|
-
//
|
|
407
|
+
// ── Initialize: local-first population.
|
|
408
|
+
//
|
|
409
|
+
// Reply / Reply-All / Forward callers pre-populate `init.accounts` with the
|
|
410
|
+
// full account list (app.ts:openCompose). In that common case we do NOT need
|
|
411
|
+
// to call getAccounts() — everything required to fill the compose form is
|
|
412
|
+
// already in sessionStorage and reads synchronously. That turns "click Reply"
|
|
413
|
+
// into an instant-open instead of "wait for getAccounts IPC to respond,
|
|
414
|
+
// which can take >120s when the service is busy syncing / hung on IMAP".
|
|
415
|
+
//
|
|
416
|
+
// getAccounts is still called (non-blocking) to refresh the dropdown with
|
|
417
|
+
// the freshest data — and it IS awaited only in the fallback path where
|
|
418
|
+
// init doesn't have an account list (message-viewer's Edit Draft passes
|
|
419
|
+
// init.accounts=[]).
|
|
388
420
|
(async () => {
|
|
389
|
-
let accounts = [];
|
|
390
|
-
try {
|
|
391
|
-
accounts = await getAccounts();
|
|
392
|
-
}
|
|
393
|
-
catch (e) {
|
|
394
|
-
console.error("Failed to load accounts:", e);
|
|
395
|
-
}
|
|
396
421
|
const stored = sessionStorage.getItem("composeInit");
|
|
397
422
|
if (stored) {
|
|
398
423
|
sessionStorage.removeItem("composeInit");
|
|
399
424
|
const init = JSON.parse(stored);
|
|
400
|
-
if (
|
|
401
|
-
init.
|
|
402
|
-
|
|
425
|
+
if (init.accounts && init.accounts.length > 0) {
|
|
426
|
+
// Happy path — init is complete. Apply immediately. Kick
|
|
427
|
+
// getAccounts in the background to refresh the dropdown if the
|
|
428
|
+
// user keeps compose open long enough for the result.
|
|
429
|
+
applyInit(init);
|
|
430
|
+
getAccounts().then((fresh) => {
|
|
431
|
+
if (Array.isArray(fresh) && fresh.length > 0) {
|
|
432
|
+
init.accounts = fresh;
|
|
433
|
+
// Re-populate the From dropdown only — don't clobber
|
|
434
|
+
// anything the user may have already typed.
|
|
435
|
+
try {
|
|
436
|
+
populateFromOptions(fresh);
|
|
437
|
+
}
|
|
438
|
+
catch { /* */ }
|
|
439
|
+
}
|
|
440
|
+
}).catch(() => { });
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
// Edit Draft / other callers that didn't pre-fill accounts.
|
|
444
|
+
// Have to wait on getAccounts here — the From dropdown needs it.
|
|
445
|
+
let fresh = [];
|
|
446
|
+
try {
|
|
447
|
+
fresh = await getAccounts();
|
|
448
|
+
}
|
|
449
|
+
catch (e) {
|
|
450
|
+
console.error("Failed to load accounts:", e);
|
|
451
|
+
}
|
|
452
|
+
init.accounts = fresh;
|
|
453
|
+
applyInit(init);
|
|
454
|
+
}
|
|
403
455
|
}
|
|
404
456
|
else {
|
|
457
|
+
let accounts = [];
|
|
458
|
+
try {
|
|
459
|
+
accounts = await getAccounts();
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
console.error("Failed to load accounts:", e);
|
|
463
|
+
}
|
|
405
464
|
populateFromOptions(accounts);
|
|
406
465
|
toInput.focus();
|
|
407
466
|
}
|
package/client/icon.ico
ADDED
|
Binary file
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -118,6 +118,20 @@
|
|
|
118
118
|
aiTransform: function(req) {
|
|
119
119
|
return callNode("aiTransform", req);
|
|
120
120
|
},
|
|
121
|
+
// Restart the background service so edits to accounts.jsonc /
|
|
122
|
+
// allowlist.jsonc / other live config take effect without the user
|
|
123
|
+
// having to type `mailx -kill`. The service spawns a fresh detached
|
|
124
|
+
// daemon and then exits; the new daemon takes over.
|
|
125
|
+
restartDaemon: function() {
|
|
126
|
+
return new Promise(function(resolve) {
|
|
127
|
+
var id = String(++_callbackId);
|
|
128
|
+
_callbacks[id] = { resolve: resolve, reject: resolve, timer: setTimeout(function() {
|
|
129
|
+
delete _callbacks[id];
|
|
130
|
+
resolve({ ok: true, status: "service-gone" });
|
|
131
|
+
}, 5000) };
|
|
132
|
+
try { window.ipc.postMessage(JSON.stringify({ _action: "restartDaemon", _cbid: id })); } catch(e) { resolve({ ok: false }); }
|
|
133
|
+
});
|
|
134
|
+
},
|
|
121
135
|
searchContacts: function(query) {
|
|
122
136
|
return callNode("searchContacts", { query: query });
|
|
123
137
|
},
|
|
@@ -396,7 +396,10 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
396
396
|
&:hover { background: var(--color-bg-hover); }
|
|
397
397
|
&.selected { background: var(--color-brand); color: var(--color-brand-dark); font-weight: 500; }
|
|
398
398
|
&.unread {
|
|
399
|
-
|
|
399
|
+
/* Thunderbird-level emphasis: true bold (700) rather than semibold
|
|
400
|
+
* (600). At smaller row heights the weight contrast is the cue the
|
|
401
|
+
* eye tracks — color shifts alone read as noise on a dense list. */
|
|
402
|
+
font-weight: 700;
|
|
400
403
|
color: var(--color-unread);
|
|
401
404
|
}
|
|
402
405
|
}
|
|
@@ -479,7 +482,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
479
482
|
}
|
|
480
483
|
.ml-thread-popup-item:hover { background: var(--color-bg-hover); }
|
|
481
484
|
.ml-thread-popup-item.unread .ml-thread-popup-from,
|
|
482
|
-
.ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight:
|
|
485
|
+
.ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight: 700; }
|
|
483
486
|
.ml-thread-popup-from {
|
|
484
487
|
grid-column: 1;
|
|
485
488
|
grid-row: 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.324",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"client"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"build": "npm run build --workspaces --if-present && tsc -p bin",
|
|
15
|
+
"build": "npm run build --workspaces --if-present && tsc -p bin && node bin/build-icon-ico.js",
|
|
16
16
|
"watch": "tsc -w",
|
|
17
17
|
"start": "node --watch packages/mailx-server/index.js",
|
|
18
18
|
"start:prod": "node packages/mailx-server/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.7",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.343",
|
|
28
28
|
"@bobfrankston/mailx-host": "^0.1.3",
|
|
29
29
|
"@capacitor/android": "^8.3.0",
|
|
30
30
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"@bobfrankston/iflow-node": "^0.1.7",
|
|
89
89
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
90
90
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
91
|
-
"@bobfrankston/msger": "^0.1.
|
|
91
|
+
"@bobfrankston/msger": "^0.1.343",
|
|
92
92
|
"@bobfrankston/mailx-host": "^0.1.3",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -249,6 +249,7 @@ export declare class ImapManager extends EventEmitter {
|
|
|
249
249
|
/** Stop Outbox worker */
|
|
250
250
|
stopOutboxWorker(): void;
|
|
251
251
|
private configWatchers;
|
|
252
|
+
private cloudPollTimers;
|
|
252
253
|
/** Watch the local config files for external changes. On change, emit
|
|
253
254
|
* configChanged so the UI can show a "restart to apply" banner. Uses
|
|
254
255
|
* a debounce to coalesce rapid writes from save tools. */
|
|
@@ -564,6 +564,11 @@ export class ImapManager extends EventEmitter {
|
|
|
564
564
|
const folders = await client.getFolderList();
|
|
565
565
|
console.log(` [diag] ${accountId}: getFolderList done in ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
566
566
|
const specialFolders = client.getSpecialFolders(folders);
|
|
567
|
+
// Collect server paths so we can prune anything the server no longer
|
|
568
|
+
// has (user-renamed / -deleted / case-flipped a folder from another
|
|
569
|
+
// client). IMAP paths are case-sensitive, so "Foo" → "foo" is a real
|
|
570
|
+
// delete+create of two distinct mailboxes.
|
|
571
|
+
const serverPaths = new Set();
|
|
567
572
|
for (const folder of folders) {
|
|
568
573
|
// Skip non-selectable folders (virtual parents like "Added", "Added2")
|
|
569
574
|
const flags = folder.flags;
|
|
@@ -584,6 +589,34 @@ export class ImapManager extends EventEmitter {
|
|
|
584
589
|
else if (specialFolders.archive === folder.path)
|
|
585
590
|
specialUse = "archive";
|
|
586
591
|
this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
|
|
592
|
+
serverPaths.add(folder.path);
|
|
593
|
+
}
|
|
594
|
+
// Prune: any local folder whose exact path (case-sensitive) isn't in
|
|
595
|
+
// the server's list has been deleted or renamed server-side. Safety
|
|
596
|
+
// rails: only prune when the server returned a non-empty list (empty
|
|
597
|
+
// result is more likely a transient protocol / auth error than "all
|
|
598
|
+
// your folders were deleted"). Never prune INBOX under any
|
|
599
|
+
// circumstances — even a broken server response shouldn't make us
|
|
600
|
+
// drop the account's primary mailbox. All other special-use folders
|
|
601
|
+
// ARE prunable: if the user actually deleted Sent on the server,
|
|
602
|
+
// we should reflect that locally, and the next sync will re-detect
|
|
603
|
+
// the server's real Sent folder and re-upsert.
|
|
604
|
+
if (folders.length > 0) {
|
|
605
|
+
const localFolders = this.db.getFolders(accountId);
|
|
606
|
+
const stale = localFolders.filter(f => !serverPaths.has(f.path) &&
|
|
607
|
+
f.specialUse !== "inbox");
|
|
608
|
+
for (const f of stale) {
|
|
609
|
+
console.log(` [sync] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — no longer on server`);
|
|
610
|
+
try {
|
|
611
|
+
this.db.deleteFolder(f.id);
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
console.error(` [sync] ${accountId}: prune failed for "${f.path}": ${e.message}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (stale.length > 0) {
|
|
618
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
619
|
+
}
|
|
587
620
|
}
|
|
588
621
|
this.emit("syncProgress", accountId, "folders", 100);
|
|
589
622
|
// Notify UI that folder structure changed — triggers tree re-render
|
|
@@ -2847,6 +2880,7 @@ export class ImapManager extends EventEmitter {
|
|
|
2847
2880
|
}
|
|
2848
2881
|
// ── Config file watcher ──
|
|
2849
2882
|
configWatchers = [];
|
|
2883
|
+
cloudPollTimers = [];
|
|
2850
2884
|
/** Watch the local config files for external changes. On change, emit
|
|
2851
2885
|
* configChanged so the UI can show a "restart to apply" banner. Uses
|
|
2852
2886
|
* a debounce to coalesce rapid writes from save tools. */
|
|
@@ -2875,6 +2909,55 @@ export class ImapManager extends EventEmitter {
|
|
|
2875
2909
|
console.error(` [watch] Failed to watch ${filename}: ${e.message}`);
|
|
2876
2910
|
}
|
|
2877
2911
|
}
|
|
2912
|
+
// GDrive has no push/watch for arbitrary Drive files, so edits on
|
|
2913
|
+
// another device (or via Drive web) never fire fs.watch locally.
|
|
2914
|
+
// Poll the cloud copies of the replicated-to-cloud config files
|
|
2915
|
+
// (accounts.jsonc, allowlist.jsonc, clients.jsonc) every 3 minutes,
|
|
2916
|
+
// compare to local, and write-through on difference. The local
|
|
2917
|
+
// fs.watch above then picks up the write and emits configChanged.
|
|
2918
|
+
// config.jsonc is per-machine / local-only — never polled.
|
|
2919
|
+
const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
|
|
2920
|
+
const CLOUD_POLL_MS = 3 * 60 * 1000;
|
|
2921
|
+
const pollCloud = async () => {
|
|
2922
|
+
let cloudRead;
|
|
2923
|
+
try {
|
|
2924
|
+
({ cloudRead } = await import("@bobfrankston/mailx-settings"));
|
|
2925
|
+
}
|
|
2926
|
+
catch {
|
|
2927
|
+
return; /* cloud module unavailable */
|
|
2928
|
+
}
|
|
2929
|
+
for (const filename of cloudFiles) {
|
|
2930
|
+
try {
|
|
2931
|
+
const cloudContent = await cloudRead(filename);
|
|
2932
|
+
if (!cloudContent)
|
|
2933
|
+
continue;
|
|
2934
|
+
const localPath = path.join(configDir, filename);
|
|
2935
|
+
let localContent = null;
|
|
2936
|
+
try {
|
|
2937
|
+
localContent = fs.readFileSync(localPath, "utf-8");
|
|
2938
|
+
}
|
|
2939
|
+
catch { /* missing */ }
|
|
2940
|
+
if (localContent === cloudContent)
|
|
2941
|
+
continue;
|
|
2942
|
+
// Cloud copy differs — write through so watchers / downstream
|
|
2943
|
+
// readers see the new value. fs.watch above will fire and
|
|
2944
|
+
// emit configChanged → UI banner.
|
|
2945
|
+
fs.writeFileSync(localPath, cloudContent);
|
|
2946
|
+
console.log(` [cloud-poll] ${filename} updated from cloud copy`);
|
|
2947
|
+
}
|
|
2948
|
+
catch (e) {
|
|
2949
|
+
// Drive unreachable, auth expired, file missing in cloud —
|
|
2950
|
+
// silent retry on next tick; no user-visible fallout.
|
|
2951
|
+
console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
};
|
|
2955
|
+
// First poll ~10s after startup, then every 3 min.
|
|
2956
|
+
setTimeout(() => {
|
|
2957
|
+
pollCloud();
|
|
2958
|
+
const interval = setInterval(pollCloud, CLOUD_POLL_MS);
|
|
2959
|
+
this.cloudPollTimers.push(interval);
|
|
2960
|
+
}, 10_000);
|
|
2878
2961
|
}
|
|
2879
2962
|
/** Stop all config file watchers */
|
|
2880
2963
|
stopWatchingConfig() {
|
|
@@ -2885,6 +2968,13 @@ export class ImapManager extends EventEmitter {
|
|
|
2885
2968
|
catch { /* ignore */ }
|
|
2886
2969
|
}
|
|
2887
2970
|
this.configWatchers = [];
|
|
2971
|
+
for (const t of this.cloudPollTimers) {
|
|
2972
|
+
try {
|
|
2973
|
+
clearInterval(t);
|
|
2974
|
+
}
|
|
2975
|
+
catch { /* ignore */ }
|
|
2976
|
+
}
|
|
2977
|
+
this.cloudPollTimers = [];
|
|
2888
2978
|
}
|
|
2889
2979
|
// ── Google Contacts Sync ──
|
|
2890
2980
|
contactsSyncToken = null;
|
|
@@ -587,16 +587,33 @@ export class MailxService {
|
|
|
587
587
|
throw new Error("Folder not found");
|
|
588
588
|
const client = this.imapManager.createPublicClient(accountId);
|
|
589
589
|
try {
|
|
590
|
-
|
|
591
|
-
|
|
590
|
+
try {
|
|
591
|
+
if (client.deleteMailbox) {
|
|
592
|
+
await client.deleteMailbox(folder.path);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
await client.withConnection(async () => {
|
|
596
|
+
await client.client.mailboxDelete(folder.path);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
592
599
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
600
|
+
catch (e) {
|
|
601
|
+
// Server already doesn't have this folder — common case when
|
|
602
|
+
// the user deleted / renamed it from another client and mailx
|
|
603
|
+
// is still showing the stale local row. Silently treat as
|
|
604
|
+
// success and proceed with local cleanup; the user's intent
|
|
605
|
+
// ("make this go away") is met either way.
|
|
606
|
+
const msg = String(e?.message || e || "").toLowerCase();
|
|
607
|
+
const alreadyGone = /nonexistent|does not exist|no such|not found|NO \[.*\] Mailbox|404/i.test(msg);
|
|
608
|
+
if (!alreadyGone)
|
|
609
|
+
throw e;
|
|
610
|
+
console.log(` [folder] ${accountId} delete "${folder.path}": server says already gone — cleaning local DB`);
|
|
597
611
|
}
|
|
598
612
|
this.db.deleteFolder(folderId);
|
|
599
|
-
|
|
613
|
+
try {
|
|
614
|
+
await client.logout();
|
|
615
|
+
}
|
|
616
|
+
catch { /* ignore */ }
|
|
600
617
|
}
|
|
601
618
|
finally {
|
|
602
619
|
try {
|
|
@@ -647,6 +664,16 @@ export class MailxService {
|
|
|
647
664
|
}
|
|
648
665
|
// ── Drafts ──
|
|
649
666
|
async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId) {
|
|
667
|
+
// Local-first: commit the draft to the local filesystem synchronously
|
|
668
|
+
// and return immediately. The IMAP APPEND (and the previous-draft
|
|
669
|
+
// delete) run in the background. Previously this method awaited IMAP
|
|
670
|
+
// inline, which produced the 30/120s `mailxapi timeout: saveDraft`
|
|
671
|
+
// the user reported — every IMAP stall (slow server, hung OAuth,
|
|
672
|
+
// maxed connection pool) froze autosave. The local `.eml` written
|
|
673
|
+
// below is the user's crash-safety net; IMAP is a sync target, not
|
|
674
|
+
// a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
|
|
675
|
+
// so the reconciler can de-duplicate on the server by header search
|
|
676
|
+
// even without the previousDraftUid round-trip.
|
|
650
677
|
const settings = loadSettings();
|
|
651
678
|
const account = settings.accounts.find(a => a.id === accountId);
|
|
652
679
|
if (!account)
|
|
@@ -663,7 +690,8 @@ export class MailxService {
|
|
|
663
690
|
`MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
|
|
664
691
|
].filter(h => h !== null).join("\r\n");
|
|
665
692
|
const raw = `${headers}\r\n\r\n${bodyEncoded}`;
|
|
666
|
-
//
|
|
693
|
+
// Local commit: write editing copy to disk. Crash recovery lives in
|
|
694
|
+
// the last 3 files. Synchronous fs (~ms) so the caller returns fast.
|
|
667
695
|
try {
|
|
668
696
|
const editingDir = path.join(getConfigDir(), "sending", accountId, "editing");
|
|
669
697
|
fs.mkdirSync(editingDir, { recursive: true });
|
|
@@ -677,9 +705,16 @@ export class MailxService {
|
|
|
677
705
|
fs.unlinkSync(path.join(editingDir, files.shift()));
|
|
678
706
|
}
|
|
679
707
|
}
|
|
680
|
-
catch { /*
|
|
681
|
-
|
|
682
|
-
|
|
708
|
+
catch { /* non-fatal — draft stays in memory at least */ }
|
|
709
|
+
// Background reconcile to server Drafts folder. Fire-and-forget —
|
|
710
|
+
// the ACK to the client is already on its way.
|
|
711
|
+
this.imapManager.saveDraft(accountId, raw, previousDraftUid, id).catch((e) => {
|
|
712
|
+
console.error(` [draft] background IMAP save failed for ${id}: ${e?.message || e}`);
|
|
713
|
+
// Surface as an event so the UI can show a status-bar hint without
|
|
714
|
+
// blocking the caller. Draft is preserved on disk regardless.
|
|
715
|
+
this.emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
|
|
716
|
+
});
|
|
717
|
+
return { draftUid: null, draftId: id };
|
|
683
718
|
}
|
|
684
719
|
async deleteDraft(accountId, draftUid, draftId) {
|
|
685
720
|
await this.imapManager.deleteDraft(accountId, draftUid, draftId);
|