@bobfrankston/mailx 1.0.317 → 1.0.327
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/icon.ico +0 -0
- package/client/lib/mailxapi.js +14 -0
- package/client/styles/components.css +19 -2
- package/package.json +4 -4
- package/packages/mailx-imap/index.d.ts +1 -0
- package/packages/mailx-imap/index.js +57 -0
- package/packages/mailx-settings/index.js +14 -0
|
@@ -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;
|
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
|
},
|
|
@@ -378,6 +378,15 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
378
378
|
grid-template-columns: subgrid;
|
|
379
379
|
align-content: start;
|
|
380
380
|
scrollbar-gutter: stable;
|
|
381
|
+
/* Android WebView / Chromium: without an explicit touch-action, the
|
|
382
|
+
* browser waits on JS touch listeners (passive or not) before deciding
|
|
383
|
+
* to scroll. That delay makes the first row feel like it got tapped
|
|
384
|
+
* before the scroll actually starts. pan-y tells the compositor: treat
|
|
385
|
+
* any vertical drag as a scroll, don't wait on JS. overscroll-behavior
|
|
386
|
+
* contains the scroll so we don't chain into the outer page / pull-to-
|
|
387
|
+
* refresh when the user flicks at top/bottom. */
|
|
388
|
+
touch-action: pan-y;
|
|
389
|
+
overscroll-behavior-y: contain;
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
.ml-row {
|
|
@@ -387,6 +396,11 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
387
396
|
padding: var(--gap-sm) var(--gap-sm);
|
|
388
397
|
border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
|
|
389
398
|
cursor: pointer;
|
|
399
|
+
/* Disable the 300ms double-tap-to-zoom delay on rows. Double-tap zoom
|
|
400
|
+
* makes no sense on a message row anyway; without this, Android often
|
|
401
|
+
* classifies a scroll-starting tap as a delayed-click because it's
|
|
402
|
+
* still in the double-tap wait window. */
|
|
403
|
+
touch-action: manipulation;
|
|
390
404
|
font-size: var(--font-size-base);
|
|
391
405
|
font-weight: 400;
|
|
392
406
|
color: var(--color-text);
|
|
@@ -396,7 +410,10 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
396
410
|
&:hover { background: var(--color-bg-hover); }
|
|
397
411
|
&.selected { background: var(--color-brand); color: var(--color-brand-dark); font-weight: 500; }
|
|
398
412
|
&.unread {
|
|
399
|
-
|
|
413
|
+
/* Thunderbird-level emphasis: true bold (700) rather than semibold
|
|
414
|
+
* (600). At smaller row heights the weight contrast is the cue the
|
|
415
|
+
* eye tracks — color shifts alone read as noise on a dense list. */
|
|
416
|
+
font-weight: 700;
|
|
400
417
|
color: var(--color-unread);
|
|
401
418
|
}
|
|
402
419
|
}
|
|
@@ -479,7 +496,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
479
496
|
}
|
|
480
497
|
.ml-thread-popup-item:hover { background: var(--color-bg-hover); }
|
|
481
498
|
.ml-thread-popup-item.unread .ml-thread-popup-from,
|
|
482
|
-
.ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight:
|
|
499
|
+
.ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight: 700; }
|
|
483
500
|
.ml-thread-popup-from {
|
|
484
501
|
grid-column: 1;
|
|
485
502
|
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.327",
|
|
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.344",
|
|
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.344",
|
|
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. */
|
|
@@ -2880,6 +2880,7 @@ export class ImapManager extends EventEmitter {
|
|
|
2880
2880
|
}
|
|
2881
2881
|
// ── Config file watcher ──
|
|
2882
2882
|
configWatchers = [];
|
|
2883
|
+
cloudPollTimers = [];
|
|
2883
2884
|
/** Watch the local config files for external changes. On change, emit
|
|
2884
2885
|
* configChanged so the UI can show a "restart to apply" banner. Uses
|
|
2885
2886
|
* a debounce to coalesce rapid writes from save tools. */
|
|
@@ -2908,6 +2909,55 @@ export class ImapManager extends EventEmitter {
|
|
|
2908
2909
|
console.error(` [watch] Failed to watch ${filename}: ${e.message}`);
|
|
2909
2910
|
}
|
|
2910
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);
|
|
2911
2961
|
}
|
|
2912
2962
|
/** Stop all config file watchers */
|
|
2913
2963
|
stopWatchingConfig() {
|
|
@@ -2918,6 +2968,13 @@ export class ImapManager extends EventEmitter {
|
|
|
2918
2968
|
catch { /* ignore */ }
|
|
2919
2969
|
}
|
|
2920
2970
|
this.configWatchers = [];
|
|
2971
|
+
for (const t of this.cloudPollTimers) {
|
|
2972
|
+
try {
|
|
2973
|
+
clearInterval(t);
|
|
2974
|
+
}
|
|
2975
|
+
catch { /* ignore */ }
|
|
2976
|
+
}
|
|
2977
|
+
this.cloudPollTimers = [];
|
|
2921
2978
|
}
|
|
2922
2979
|
// ── Google Contacts Sync ──
|
|
2923
2980
|
contactsSyncToken = null;
|
|
@@ -307,36 +307,43 @@ const PROVIDERS = {
|
|
|
307
307
|
label: "Gmail",
|
|
308
308
|
imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
|
|
309
309
|
smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
|
|
310
|
+
spam: "SPAM", // Gmail labels, mailx tree shows as "SPAM"
|
|
310
311
|
},
|
|
311
312
|
"googlemail.com": {
|
|
312
313
|
label: "Gmail",
|
|
313
314
|
imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
|
|
314
315
|
smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
|
|
316
|
+
spam: "SPAM",
|
|
315
317
|
},
|
|
316
318
|
"outlook.com": {
|
|
317
319
|
label: "Outlook",
|
|
318
320
|
imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
|
|
319
321
|
smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
|
|
322
|
+
spam: "Junk Email",
|
|
320
323
|
},
|
|
321
324
|
"hotmail.com": {
|
|
322
325
|
label: "Hotmail",
|
|
323
326
|
imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
|
|
324
327
|
smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
|
|
328
|
+
spam: "Junk Email",
|
|
325
329
|
},
|
|
326
330
|
"yahoo.com": {
|
|
327
331
|
label: "Yahoo",
|
|
328
332
|
imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
|
|
329
333
|
smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
|
|
334
|
+
spam: "Bulk Mail",
|
|
330
335
|
},
|
|
331
336
|
"aol.com": {
|
|
332
337
|
label: "AOL",
|
|
333
338
|
imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
|
|
334
339
|
smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
|
|
340
|
+
spam: "Bulk Mail",
|
|
335
341
|
},
|
|
336
342
|
"icloud.com": {
|
|
337
343
|
label: "iCloud",
|
|
338
344
|
imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
|
|
339
345
|
smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
|
|
346
|
+
spam: "Junk",
|
|
340
347
|
},
|
|
341
348
|
};
|
|
342
349
|
/** Fill in provider defaults for an account based on email domain */
|
|
@@ -372,6 +379,13 @@ function normalizeAccount(acct, globalName) {
|
|
|
372
379
|
relayDomains: acct.relayDomains,
|
|
373
380
|
deliveredToPrefix: acct.deliveredToPrefix,
|
|
374
381
|
identityDomains: acct.identityDomains,
|
|
382
|
+
// Spam folder: explicit account config wins; otherwise fall back to
|
|
383
|
+
// the provider default (e.g. Gmail ships with built-in SPAM; Outlook
|
|
384
|
+
// with "Junk Email"). Before 2026-04-21 this field was dropped by
|
|
385
|
+
// normalizeAccount entirely — silent regression even for accounts
|
|
386
|
+
// that had it configured. `acct.spam` first so a user-set value on
|
|
387
|
+
// a recognized provider still overrides the default.
|
|
388
|
+
spam: acct.spam !== undefined ? acct.spam : provider?.spam,
|
|
375
389
|
};
|
|
376
390
|
}
|
|
377
391
|
// ── Defaults ──
|