@bobfrankston/mailx 1.0.450 → 1.0.452
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/mailx.js.map +1 -0
- package/bin/mailx.ts +1498 -0
- package/bin/postinstall.js.map +1 -0
- package/bin/postinstall.ts +41 -0
- package/bin/tsconfig.json +10 -0
- package/client/.gitattributes +10 -0
- package/client/app.js +51 -2
- package/client/app.js.map +1 -0
- package/client/app.ts +3112 -0
- package/client/components/address-book.js.map +1 -0
- package/client/components/address-book.ts +204 -0
- package/client/components/alarms.js.map +1 -0
- package/client/components/alarms.ts +276 -0
- package/client/components/calendar-sidebar.js.map +1 -0
- package/client/components/calendar-sidebar.ts +474 -0
- package/client/components/calendar.js.map +1 -0
- package/client/components/calendar.ts +211 -0
- package/client/components/context-menu.js.map +1 -0
- package/client/components/context-menu.ts +95 -0
- package/client/components/folder-picker.js.map +1 -0
- package/client/components/folder-picker.ts +127 -0
- package/client/components/folder-tree.js.map +1 -0
- package/client/components/folder-tree.ts +1069 -0
- package/client/components/message-list.js.map +1 -0
- package/client/components/message-list.ts +1129 -0
- package/client/components/message-viewer.js.map +1 -0
- package/client/components/message-viewer.ts +1257 -0
- package/client/components/outbox-view.js.map +1 -0
- package/client/components/outbox-view.ts +102 -0
- package/client/components/tasks.js.map +1 -0
- package/client/components/tasks.ts +234 -0
- package/client/compose/compose.js.map +1 -0
- package/client/compose/compose.ts +1231 -0
- package/client/compose/editor.js.map +1 -0
- package/client/compose/editor.ts +599 -0
- package/client/compose/ghost-text.js.map +1 -0
- package/client/compose/ghost-text.ts +140 -0
- package/client/index.html +1 -0
- package/client/lib/android-bootstrap.js.map +1 -0
- package/client/lib/android-bootstrap.ts +9 -0
- package/client/lib/api-client.js.map +1 -0
- package/client/lib/api-client.ts +439 -0
- package/client/lib/local-service.js.map +1 -0
- package/client/lib/local-service.ts +646 -0
- package/client/lib/local-store.js.map +1 -0
- package/client/lib/local-store.ts +283 -0
- package/client/lib/message-state.js.map +1 -0
- package/client/lib/message-state.ts +140 -0
- package/client/tsconfig.json +19 -0
- package/package.json +15 -15
- package/packages/mailx-api/.gitattributes +10 -0
- package/packages/mailx-api/index.d.ts.map +1 -0
- package/packages/mailx-api/index.js.map +1 -0
- package/packages/mailx-api/index.ts +283 -0
- package/packages/mailx-api/tsconfig.json +9 -0
- package/packages/mailx-compose/.gitattributes +10 -0
- package/packages/mailx-compose/index.d.ts.map +1 -0
- package/packages/mailx-compose/index.js.map +1 -0
- package/packages/mailx-compose/index.ts +85 -0
- package/packages/mailx-compose/tsconfig.json +9 -0
- package/packages/mailx-core/index.d.ts.map +1 -0
- package/packages/mailx-core/index.js.map +1 -0
- package/packages/mailx-core/index.ts +424 -0
- package/packages/mailx-core/ipc.d.ts.map +1 -0
- package/packages/mailx-core/ipc.js.map +1 -0
- package/packages/mailx-core/ipc.ts +62 -0
- package/packages/mailx-core/tsconfig.json +9 -0
- package/packages/mailx-host/.gitattributes +10 -0
- package/packages/mailx-host/index.d.ts.map +1 -0
- package/packages/mailx-host/index.js.map +1 -0
- package/packages/mailx-host/index.ts +38 -0
- package/packages/mailx-host/package.json +10 -2
- package/packages/mailx-host/tsconfig.json +9 -0
- package/packages/mailx-send/.gitattributes +10 -0
- package/packages/mailx-send/cli-queue.d.ts.map +1 -0
- package/packages/mailx-send/cli-queue.js.map +1 -0
- package/packages/mailx-send/cli-queue.ts +62 -0
- package/packages/mailx-send/cli-send.d.ts.map +1 -0
- package/packages/mailx-send/cli-send.js.map +1 -0
- package/packages/mailx-send/cli-send.ts +83 -0
- package/packages/mailx-send/cli.d.ts.map +1 -0
- package/packages/mailx-send/cli.js.map +1 -0
- package/packages/mailx-send/cli.ts +126 -0
- package/packages/mailx-send/index.d.ts.map +1 -0
- package/packages/mailx-send/index.js.map +1 -0
- package/packages/mailx-send/index.ts +333 -0
- package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
- package/packages/mailx-send/mailsend/cli.js.map +1 -0
- package/packages/mailx-send/mailsend/cli.ts +81 -0
- package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
- package/packages/mailx-send/mailsend/index.js.map +1 -0
- package/packages/mailx-send/mailsend/index.ts +333 -0
- package/packages/mailx-send/mailsend/package-lock.json +65 -0
- package/packages/mailx-send/mailsend/tsconfig.json +21 -0
- package/packages/mailx-send/package-lock.json +65 -0
- package/packages/mailx-send/package.json +1 -1
- package/packages/mailx-send/tsconfig.json +21 -0
- package/packages/mailx-server/.gitattributes +10 -0
- package/packages/mailx-server/index.d.ts.map +1 -0
- package/packages/mailx-server/index.js.map +1 -0
- package/packages/mailx-server/index.ts +429 -0
- package/packages/mailx-server/tsconfig.json +9 -0
- package/packages/mailx-service/google-sync.d.ts.map +1 -0
- package/packages/mailx-service/google-sync.js.map +1 -0
- package/packages/mailx-service/google-sync.ts +238 -0
- package/packages/mailx-service/index.d.ts.map +1 -0
- package/packages/mailx-service/index.js.map +1 -0
- package/packages/mailx-service/index.ts +2461 -0
- package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -0
- package/packages/mailx-service/jsonrpc.ts +268 -0
- package/packages/mailx-service/tsconfig.json +9 -0
- package/packages/mailx-settings/.gitattributes +10 -0
- package/packages/mailx-settings/cloud.d.ts.map +1 -0
- package/packages/mailx-settings/cloud.js.map +1 -0
- package/packages/mailx-settings/cloud.ts +388 -0
- package/packages/mailx-settings/index.d.ts.map +1 -0
- package/packages/mailx-settings/index.js.map +1 -0
- package/packages/mailx-settings/index.ts +892 -0
- package/packages/mailx-settings/tsconfig.json +9 -0
- package/packages/mailx-store/.gitattributes +10 -0
- package/packages/mailx-store/db.d.ts.map +1 -0
- package/packages/mailx-store/db.js.map +1 -0
- package/packages/mailx-store/db.ts +2007 -0
- package/packages/mailx-store/file-store.d.ts.map +1 -0
- package/packages/mailx-store/file-store.js.map +1 -0
- package/packages/mailx-store/file-store.ts +82 -0
- package/packages/mailx-store/index.d.ts.map +1 -0
- package/packages/mailx-store/index.js.map +1 -0
- package/packages/mailx-store/index.ts +7 -0
- package/packages/mailx-store/tsconfig.json +9 -0
- package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
- package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
- package/packages/mailx-store-web/db.d.ts.map +1 -0
- package/packages/mailx-store-web/db.js.map +1 -0
- package/packages/mailx-store-web/db.ts +756 -0
- package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
- package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
- package/packages/mailx-store-web/gmail-api-web.ts +11 -0
- package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
- package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
- package/packages/mailx-store-web/imap-web-provider.ts +156 -0
- package/packages/mailx-store-web/index.d.ts.map +1 -0
- package/packages/mailx-store-web/index.js.map +1 -0
- package/packages/mailx-store-web/index.ts +10 -0
- package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
- package/packages/mailx-store-web/main-thread-host.js.map +1 -0
- package/packages/mailx-store-web/main-thread-host.ts +322 -0
- package/packages/mailx-store-web/package.json +4 -4
- package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
- package/packages/mailx-store-web/provider-types.js.map +1 -0
- package/packages/mailx-store-web/provider-types.ts +7 -0
- package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
- package/packages/mailx-store-web/sync-manager.js.map +1 -0
- package/packages/mailx-store-web/sync-manager.ts +508 -0
- package/packages/mailx-store-web/tsconfig.json +10 -0
- package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
- package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
- package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
- package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
- package/packages/mailx-store-web/web-message-store.js.map +1 -0
- package/packages/mailx-store-web/web-message-store.ts +97 -0
- package/packages/mailx-store-web/web-service.d.ts.map +1 -0
- package/packages/mailx-store-web/web-service.js.map +1 -0
- package/packages/mailx-store-web/web-service.ts +616 -0
- package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
- package/packages/mailx-store-web/web-settings.js.map +1 -0
- package/packages/mailx-store-web/web-settings.ts +522 -0
- package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
- package/packages/mailx-store-web/worker-entry.js.map +1 -0
- package/packages/mailx-store-web/worker-entry.ts +215 -0
- package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
- package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
- package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
- package/packages/mailx-types/.gitattributes +10 -0
- package/packages/mailx-types/index.d.ts.map +1 -0
- package/packages/mailx-types/index.js.map +1 -0
- package/packages/mailx-types/index.ts +498 -0
- package/packages/mailx-types/tsconfig.json +9 -0
- package/tsconfig.base.json +2 -1
- package/tsconfig.json +9 -0
- package/build-apk.cmd +0 -3
- package/npmg.bat +0 -6
- package/packages/mailx-imap/index.d.ts +0 -442
- package/packages/mailx-imap/index.js +0 -3669
- package/packages/mailx-imap/package.json +0 -25
- package/packages/mailx-imap/providers/gmail-api.d.ts +0 -8
- package/packages/mailx-imap/providers/gmail-api.js +0 -8
- package/packages/mailx-imap/providers/types.d.ts +0 -9
- package/packages/mailx-imap/providers/types.js +0 -9
- package/packages/mailx-imap/tsconfig.tsbuildinfo +0 -1
- package/rebuild.cmd +0 -23
- package/tdview.cmd +0 -2
- package/temp.ps1 +0 -10
- package/test-smtp-direct.mjs +0 -4
- package/unbash.cmd +0 -55
- package/unwedge.cmd +0 -1
package/bin/mailx.ts
ADDED
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mailx -- email client
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* mailx Start service + open in msger (IPC, no TCP)
|
|
7
|
+
* mailx --server Start Express HTTP server (dev/remote)
|
|
8
|
+
* mailx --no-browser Start server only (headless)
|
|
9
|
+
* mailx --verbose Show console output (default: log file only)
|
|
10
|
+
* mailx --email <addr> First-time setup with email (skips prompt)
|
|
11
|
+
* mailx --import <file> Import accounts.jsonc into GDrive and merge
|
|
12
|
+
* mailx -v / --version Show version and exit
|
|
13
|
+
* mailx -kill Kill running mailx processes
|
|
14
|
+
* mailx -setup Interactive first-time setup (CLI)
|
|
15
|
+
* mailx -test Test IMAP/SMTP connectivity
|
|
16
|
+
* mailx -rebuild Wipe local cache, re-sync from IMAP
|
|
17
|
+
* mailx -repair Re-sync metadata (fix corrupt subjects) keeping .eml files
|
|
18
|
+
* mailx -reauth Clear cached OAuth tokens; next start re-consents
|
|
19
|
+
* (use when new Google scopes have been added)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import net from "node:net";
|
|
26
|
+
import { ports } from "@bobfrankston/miscinfo";
|
|
27
|
+
import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";
|
|
28
|
+
|
|
29
|
+
setAppName("mailx");
|
|
30
|
+
// Prefer the .ico (Windows Explorer / taskbar-pin shortcut uses the embedded
|
|
31
|
+
// icon resource of the pinned exe, or a Windows icon resource referenced
|
|
32
|
+
// via PKEY_AppUserModel_RelaunchIconResource — PNG can't play either role).
|
|
33
|
+
// Fall back to PNG for the in-window / tao-level icon on non-Windows.
|
|
34
|
+
{
|
|
35
|
+
const icoPath = path.resolve(import.meta.dirname, "..", "client", "icon.ico");
|
|
36
|
+
const pngPath = path.resolve(import.meta.dirname, "..", "client", "icon.png");
|
|
37
|
+
setAppIcon(fs.existsSync(icoPath) ? icoPath : pngPath);
|
|
38
|
+
}
|
|
39
|
+
const PORT = ports.mailx;
|
|
40
|
+
const args = process.argv.slice(2);
|
|
41
|
+
|
|
42
|
+
// Normalize: accept both -flag and --flag
|
|
43
|
+
function hasFlag(name: string): boolean { return args.includes(`-${name}`) || args.includes(`--${name}`); }
|
|
44
|
+
|
|
45
|
+
const verbose = hasFlag("verbose");
|
|
46
|
+
const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process
|
|
47
|
+
|
|
48
|
+
// Read our own version once — used for the instance file + upgrade check below.
|
|
49
|
+
const __selfRoot = path.join(import.meta.dirname, "..");
|
|
50
|
+
const __selfVersion: string = (() => {
|
|
51
|
+
try { return JSON.parse(fs.readFileSync(path.join(__selfRoot, "package.json"), "utf-8")).version || "unknown"; }
|
|
52
|
+
catch { return "unknown"; }
|
|
53
|
+
})();
|
|
54
|
+
const __instanceFile = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx", "instance.json");
|
|
55
|
+
|
|
56
|
+
function readInstanceFile(): { pid: number; version: string; startedAt: number } | null {
|
|
57
|
+
try {
|
|
58
|
+
const raw = fs.readFileSync(__instanceFile, "utf-8");
|
|
59
|
+
const inst = JSON.parse(raw);
|
|
60
|
+
if (typeof inst.pid === "number" && typeof inst.version === "string") return inst;
|
|
61
|
+
} catch { /* missing or unreadable — treated as no instance */ }
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
function writeInstanceFile(pid: number): void {
|
|
65
|
+
try {
|
|
66
|
+
fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
|
|
67
|
+
fs.writeFileSync(__instanceFile, JSON.stringify({ pid, version: __selfVersion, startedAt: Date.now() }, null, 2));
|
|
68
|
+
} catch { /* non-fatal */ }
|
|
69
|
+
}
|
|
70
|
+
function clearInstanceFile(): void {
|
|
71
|
+
try { fs.unlinkSync(__instanceFile); } catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
function pidAlive(pid: number): boolean {
|
|
74
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Version-mismatch upgrade: if a daemon from an older version is running when
|
|
78
|
+
// the user types `mailx`, kill it so the new one can take over. Without this,
|
|
79
|
+
// a second invocation would silently no-op (daemon exists), leaving the user
|
|
80
|
+
// on an old UI with no indication that the install has been upgraded.
|
|
81
|
+
// Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
|
|
82
|
+
// the internal --daemon respawn.
|
|
83
|
+
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
|
|
84
|
+
const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
|
|
85
|
+
if (!isDaemon && !__isCommandInvocation) {
|
|
86
|
+
const inst = readInstanceFile();
|
|
87
|
+
if (inst && pidAlive(inst.pid)) {
|
|
88
|
+
if (inst.version !== __selfVersion) {
|
|
89
|
+
console.log(`mailx: upgrading running daemon (PID ${inst.pid}) from v${inst.version} → v${__selfVersion}`);
|
|
90
|
+
try { process.kill(inst.pid, "SIGTERM"); } catch { /* already gone */ }
|
|
91
|
+
// Give it ~1.5s to exit gracefully, then verify
|
|
92
|
+
const deadline = Date.now() + 2000;
|
|
93
|
+
while (Date.now() < deadline && pidAlive(inst.pid)) {
|
|
94
|
+
const sab = new SharedArrayBuffer(4);
|
|
95
|
+
Atomics.wait(new Int32Array(sab), 0, 0, 100); // 100ms nap
|
|
96
|
+
}
|
|
97
|
+
if (pidAlive(inst.pid)) {
|
|
98
|
+
try { process.kill(inst.pid, "SIGKILL"); } catch { /* */ }
|
|
99
|
+
}
|
|
100
|
+
clearInstanceFile();
|
|
101
|
+
} else {
|
|
102
|
+
// Same version already running — nothing to do. Print so the user
|
|
103
|
+
// knows why `mailx` seems to have done nothing.
|
|
104
|
+
console.log(`mailx v${__selfVersion} is already running (PID ${inst.pid}). Use mailx -kill to stop it.`);
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
} else if (inst) {
|
|
108
|
+
// Stale instance file — PID is dead. Clean up.
|
|
109
|
+
clearInstanceFile();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Auto-detach: re-spawn as background process so terminal returns immediately
|
|
114
|
+
// Skip for: --verbose (want console), --daemon (already detached),
|
|
115
|
+
// and any command flags (setup, kill, test, etc.)
|
|
116
|
+
if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
|
|
117
|
+
const { spawn } = await import("node:child_process");
|
|
118
|
+
const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
|
|
119
|
+
// windowsHide on the spawn options below — prevents the brief
|
|
120
|
+
// console-window flash when the daemon launches.
|
|
121
|
+
detached: true,
|
|
122
|
+
stdio: "ignore",
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
});
|
|
125
|
+
child.unref();
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const setupMode = hasFlag("setup");
|
|
130
|
+
const addMode = hasFlag("add");
|
|
131
|
+
const testMode = hasFlag("test");
|
|
132
|
+
const rebuildMode = hasFlag("rebuild");
|
|
133
|
+
const repairMode = hasFlag("repair");
|
|
134
|
+
const importMode = hasFlag("import");
|
|
135
|
+
|
|
136
|
+
// Validate arguments
|
|
137
|
+
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon"];
|
|
138
|
+
for (const arg of args) {
|
|
139
|
+
const flag = arg.replace(/^--?/, "");
|
|
140
|
+
if (arg.startsWith("-") && !knownFlags.includes(flag)) {
|
|
141
|
+
console.error(`Unknown option: ${arg}`);
|
|
142
|
+
console.error("Usage: mailx [-verbose] [-kill] [-rebuild] [-v] [-setup]");
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function log(...msg: any[]): void { if (verbose) console.log("[mailx]", ...msg); }
|
|
148
|
+
|
|
149
|
+
/** Detect whether we're running with administrator / root privileges.
|
|
150
|
+
* Windows: `net session` requires admin — succeeds silently when elevated,
|
|
151
|
+
* errors "Access is denied" otherwise. Linux/Mac: check process uid.
|
|
152
|
+
* Returns true only when positively detected as elevated; on ambiguity
|
|
153
|
+
* (e.g. child_process spawn failed for non-privilege reasons), returns
|
|
154
|
+
* false so we don't block users on false positives. */
|
|
155
|
+
function isElevated(): boolean {
|
|
156
|
+
try {
|
|
157
|
+
if (process.platform === "win32") {
|
|
158
|
+
const { execSync } = require("node:child_process");
|
|
159
|
+
execSync("net session >nul 2>&1", { stdio: "ignore", windowsHide: true });
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
if (typeof (process as any).getuid === "function") {
|
|
163
|
+
return (process as any).getuid() === 0;
|
|
164
|
+
}
|
|
165
|
+
} catch { /* non-admin → net session fails */ }
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Put up a blocking warning dialog via showMessageBox. Returns the label
|
|
170
|
+
* the user clicked. The default (Quit) is first so Enter dismisses to
|
|
171
|
+
* safety. Caller decides what to do with "Continue anyway". */
|
|
172
|
+
async function warnElevated(): Promise<string> {
|
|
173
|
+
const res = await showMessageBox({
|
|
174
|
+
title: "mailx — elevated run not recommended",
|
|
175
|
+
message:
|
|
176
|
+
"mailx is running with Administrator privileges.\n\n" +
|
|
177
|
+
"This can corrupt the per-user WebView2 profile at\n" +
|
|
178
|
+
"%LOCALAPPDATA%\\msger\\webview2\\ and create admin-owned files\n" +
|
|
179
|
+
"under ~/.mailx/ that later non-admin runs can't write to\n" +
|
|
180
|
+
"(SQLite db, tokens, config).\n\n" +
|
|
181
|
+
"Quit, relaunch from a normal shell, and only use admin if\n" +
|
|
182
|
+
"you specifically know you need it. To bypass this warning\n" +
|
|
183
|
+
"(for scripted admin use), pass --allow-elevated.",
|
|
184
|
+
buttons: ["Quit", "Continue anyway"],
|
|
185
|
+
size: { width: 540, height: 340 },
|
|
186
|
+
escapeCloses: true,
|
|
187
|
+
});
|
|
188
|
+
return res.button;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Kill any running mailx server
|
|
192
|
+
if (hasFlag("kill")) {
|
|
193
|
+
log("Killing mailx processes...");
|
|
194
|
+
const { execSync } = await import("node:child_process");
|
|
195
|
+
let killed = 0;
|
|
196
|
+
|
|
197
|
+
// Try graceful exit first
|
|
198
|
+
try {
|
|
199
|
+
execSync(`curl -s -m 2 http://localhost:${PORT}/api/exit`, { stdio: "pipe" });
|
|
200
|
+
log("Sent graceful exit");
|
|
201
|
+
execSync("timeout /t 1 /nobreak", { stdio: "pipe" });
|
|
202
|
+
} catch { /* server may not be responding */ }
|
|
203
|
+
|
|
204
|
+
if (process.platform === "win32") {
|
|
205
|
+
// Kill by port
|
|
206
|
+
try {
|
|
207
|
+
const out = execSync(`netstat -ano | findstr :${PORT} | findstr LISTENING`, { encoding: "utf-8" }).trim();
|
|
208
|
+
const pids = [...new Set(out.split("\n").map(l => l.trim().split(/\s+/).pop()).filter(Boolean))];
|
|
209
|
+
for (const pid of pids) {
|
|
210
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (port ${PORT})`); killed++; } catch { /* */ }
|
|
211
|
+
}
|
|
212
|
+
} catch { /* no process on port */ }
|
|
213
|
+
|
|
214
|
+
// Kill any node.exe running mailx (server or IPC service)
|
|
215
|
+
try {
|
|
216
|
+
const ps = execSync(
|
|
217
|
+
`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match 'mailx-server|mailx\\\\packages|mailx\\\\bin' } | Select-Object -ExpandProperty ProcessId"`,
|
|
218
|
+
{ encoding: "utf-8" }
|
|
219
|
+
).trim();
|
|
220
|
+
for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
|
|
221
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (mailx node process)`); killed++; } catch { /* */ }
|
|
222
|
+
}
|
|
223
|
+
} catch { /* */ }
|
|
224
|
+
|
|
225
|
+
// Kill orphaned msgernative.exe windows. When the node process dies
|
|
226
|
+
// without cascade-killing its WebView child (old crash, forced
|
|
227
|
+
// taskkill, etc.), the msgernative.exe stays on screen and looks
|
|
228
|
+
// like a live mailx. mailx -kill should leave no trace.
|
|
229
|
+
// Scoped to exes launched for mailx by filtering CommandLine — don't
|
|
230
|
+
// touch msger windows started by other apps (msga, bbs, etc.).
|
|
231
|
+
try {
|
|
232
|
+
const ps = execSync(
|
|
233
|
+
`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='msgernative.exe'\\" | Where-Object { $_.CommandLine -match 'mailx' -or $_.Path -match 'mailx' } | Select-Object -ExpandProperty ProcessId"`,
|
|
234
|
+
{ encoding: "utf-8" }
|
|
235
|
+
).trim();
|
|
236
|
+
for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
|
|
237
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (mailx msgernative/WebView)`); killed++; } catch { /* */ }
|
|
238
|
+
}
|
|
239
|
+
} catch { /* */ }
|
|
240
|
+
} else {
|
|
241
|
+
try { execSync(`fuser -k ${PORT}/tcp`, { stdio: "pipe" }); console.log(`Killed process on port ${PORT}`); killed++; } catch { /* */ }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Clean up stale SQLite WAL/SHM files
|
|
245
|
+
const mailxDir = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
|
|
246
|
+
for (const ext of ["mailx.db-shm", "mailx.db-wal"]) {
|
|
247
|
+
const p = path.join(mailxDir, ext);
|
|
248
|
+
try { fs.unlinkSync(p); log(`Cleaned ${ext}`); } catch { /* */ }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (killed === 0) console.log("No mailx processes found");
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Re-auth: clear cached OAuth tokens so the next start forces a fresh
|
|
256
|
+
// consent flow. Needed when scopes change (e.g. Google Tasks was added
|
|
257
|
+
// 2026-04-23 but existing tokens were issued against the older scope
|
|
258
|
+
// set, so tasks API calls 403ed with "insufficient authentication
|
|
259
|
+
// scopes"). Safe — tokens are only a cache; fresh consent re-issues.
|
|
260
|
+
if (hasFlag("reauth")) {
|
|
261
|
+
const { getConfigDir } = await import("@bobfrankston/mailx-settings");
|
|
262
|
+
const tokensDir = path.join(getConfigDir(), "tokens");
|
|
263
|
+
if (!fs.existsSync(tokensDir)) {
|
|
264
|
+
console.log("No tokens directory — nothing to clear.");
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
let cleared = 0;
|
|
268
|
+
for (const entry of fs.readdirSync(tokensDir)) {
|
|
269
|
+
const userDir = path.join(tokensDir, entry);
|
|
270
|
+
try {
|
|
271
|
+
const stat = fs.statSync(userDir);
|
|
272
|
+
if (!stat.isDirectory()) continue;
|
|
273
|
+
const tokenFile = path.join(userDir, "oauth-token.json");
|
|
274
|
+
if (fs.existsSync(tokenFile)) {
|
|
275
|
+
fs.unlinkSync(tokenFile);
|
|
276
|
+
console.log(` Cleared token for ${entry}`);
|
|
277
|
+
cleared++;
|
|
278
|
+
}
|
|
279
|
+
} catch { /* skip */ }
|
|
280
|
+
}
|
|
281
|
+
console.log(cleared === 0
|
|
282
|
+
? "No cached tokens found."
|
|
283
|
+
: `Cleared ${cleared} cached token(s). Next 'mailx' start will open a browser OAuth consent so the new scopes (tasks, full contacts) get granted.`);
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Rebuild: wipe DB + message store, keep accounts/settings
|
|
288
|
+
if (rebuildMode) {
|
|
289
|
+
const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
|
|
290
|
+
const dbDir = getConfigDir();
|
|
291
|
+
const storePath = getStorePath();
|
|
292
|
+
|
|
293
|
+
console.log("Rebuilding mailx local cache...");
|
|
294
|
+
console.log(" Accounts and settings will be preserved.");
|
|
295
|
+
|
|
296
|
+
// Remove DB files
|
|
297
|
+
for (const f of ["mailx.db", "mailx.db-wal", "mailx.db-shm"]) {
|
|
298
|
+
const p = path.join(dbDir, f);
|
|
299
|
+
if (fs.existsSync(p)) { fs.unlinkSync(p); console.log(` Deleted ${f}`); }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Remove message store
|
|
303
|
+
if (fs.existsSync(storePath)) {
|
|
304
|
+
fs.rmSync(storePath, { recursive: true });
|
|
305
|
+
console.log(` Deleted message store`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
console.log(" Rebuild complete. Run 'mailx' to start fresh.");
|
|
309
|
+
process.exit(0);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Repair: re-sync metadata (subjects, flags, envelopes) without deleting stored .eml files
|
|
313
|
+
if (repairMode) {
|
|
314
|
+
const { getConfigDir } = await import("@bobfrankston/mailx-settings");
|
|
315
|
+
const dbDir = getConfigDir();
|
|
316
|
+
const dbPath = path.join(dbDir, "mailx.db");
|
|
317
|
+
|
|
318
|
+
if (!fs.existsSync(dbPath)) {
|
|
319
|
+
console.error("No database found. Run 'mailx' first to create one.");
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log("Repairing mailx metadata...");
|
|
324
|
+
console.log(" Message bodies (.eml files) will be preserved.");
|
|
325
|
+
console.log(" Clearing message metadata for re-sync...");
|
|
326
|
+
|
|
327
|
+
// Dynamic require — better-sqlite3 is a native module, not typed in bin/
|
|
328
|
+
const mod = "better-sqlite3";
|
|
329
|
+
const Database = (await import(/* webpackIgnore: true */ mod) as any).default;
|
|
330
|
+
const db = Database(dbPath);
|
|
331
|
+
db.pragma("journal_mode = WAL");
|
|
332
|
+
|
|
333
|
+
const count = (db.prepare("SELECT COUNT(*) as cnt FROM messages").get() as any).cnt;
|
|
334
|
+
db.exec("DELETE FROM messages");
|
|
335
|
+
db.exec("DELETE FROM messages_fts");
|
|
336
|
+
// Reset folder sync state so IMAP re-syncs all envelopes
|
|
337
|
+
db.exec("UPDATE folders SET total = 0, unread = 0");
|
|
338
|
+
db.close();
|
|
339
|
+
|
|
340
|
+
console.log(` Cleared ${count} message entries. Folder sync state reset.`);
|
|
341
|
+
console.log(" Run 'mailx' to re-sync from IMAP with correct encoding.");
|
|
342
|
+
process.exit(0);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Import accounts from a local file into GDrive
|
|
346
|
+
if (importMode) {
|
|
347
|
+
const importPath = args.find(a => !a.startsWith("-"));
|
|
348
|
+
if (!importPath) {
|
|
349
|
+
console.error("Usage: mailx --import <path-to-accounts.jsonc>");
|
|
350
|
+
console.error(" Reads accounts from a local file and saves to Google Drive.");
|
|
351
|
+
console.error(" Example: mailx --import ~/OneDrive/home/.mailx/accounts.jsonc");
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
const { parse: parseJsonc } = await import("jsonc-parser");
|
|
355
|
+
const absPath = path.resolve(importPath);
|
|
356
|
+
if (!fs.existsSync(absPath)) {
|
|
357
|
+
console.error(`File not found: ${absPath}`);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
const content = fs.readFileSync(absPath, "utf-8").replace(/\r/g, "");
|
|
361
|
+
const data = parseJsonc(content);
|
|
362
|
+
const accounts = data?.accounts || (Array.isArray(data) ? data : null);
|
|
363
|
+
if (!accounts || accounts.length === 0) {
|
|
364
|
+
console.error("No accounts found in file. Expected { accounts: [...] } or [...]");
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
console.log(`Found ${accounts.length} account(s) in ${absPath}`);
|
|
368
|
+
|
|
369
|
+
// Initialize cloud config (GDrive) and save
|
|
370
|
+
const { initCloudConfig, loadAccounts, saveAccounts } = await import("@bobfrankston/mailx-settings");
|
|
371
|
+
await initCloudConfig("gdrive");
|
|
372
|
+
|
|
373
|
+
// Merge: existing cloud accounts + imported, deduplicate by email
|
|
374
|
+
const existing = loadAccounts();
|
|
375
|
+
const merged = [...existing];
|
|
376
|
+
for (const acct of accounts) {
|
|
377
|
+
if (!merged.some(e => e.email === acct.email)) {
|
|
378
|
+
merged.push(acct);
|
|
379
|
+
console.log(` + ${acct.label || acct.email}`);
|
|
380
|
+
} else {
|
|
381
|
+
console.log(` = ${acct.label || acct.email} (already exists)`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Wrap with name if the source had one
|
|
385
|
+
const wrapper: any = { accounts: merged };
|
|
386
|
+
if (data?.name) wrapper.name = data.name;
|
|
387
|
+
await saveAccounts(merged);
|
|
388
|
+
console.log(`Saved ${merged.length} account(s). Run 'mailx' to start.`);
|
|
389
|
+
process.exit(0);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Version
|
|
393
|
+
if (hasFlag("v") || hasFlag("version")) {
|
|
394
|
+
const root = path.join(import.meta.dirname, "..");
|
|
395
|
+
const ver = (pkg: string): string => {
|
|
396
|
+
for (const dir of [`${root}/node_modules/${pkg}`, `${root}/node_modules/@bobfrankston/${pkg.replace("@bobfrankston/","")}`]) {
|
|
397
|
+
try { return JSON.parse(fs.readFileSync(`${dir}/package.json`, "utf-8")).version; } catch { /* */ }
|
|
398
|
+
}
|
|
399
|
+
// Check workspace packages
|
|
400
|
+
const short = pkg.replace("@bobfrankston/","");
|
|
401
|
+
try { return JSON.parse(fs.readFileSync(`${root}/packages/${short}/package.json`, "utf-8")).version; } catch { /* */ }
|
|
402
|
+
return "not found";
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
const pkg = JSON.parse(fs.readFileSync(`${root}/package.json`, "utf-8"));
|
|
406
|
+
console.log(`\x1b[1;97;44m mailx v${pkg.version} \x1b[0m`);
|
|
407
|
+
} catch { console.log("mailx (version unknown)"); }
|
|
408
|
+
console.log(` node ${process.version}`);
|
|
409
|
+
console.log(` iflow-direct ${ver("@bobfrankston/iflow-direct")}`);
|
|
410
|
+
console.log(` miscinfo ${ver("@bobfrankston/miscinfo")}`);
|
|
411
|
+
console.log(` oauth ${ver("@bobfrankston/oauthsupport")}`);
|
|
412
|
+
console.log(` store ${ver("@bobfrankston/mailx-store")}`);
|
|
413
|
+
console.log(` server ${ver("@bobfrankston/mailx-server")}`);
|
|
414
|
+
console.log(` api ${ver("@bobfrankston/mailx-api")}`);
|
|
415
|
+
console.log(` platform ${process.platform} ${process.arch}`);
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isPortInUse(port: number): Promise<boolean> {
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
const socket = net.createConnection({ port, host: "127.0.0.1" });
|
|
422
|
+
socket.once("connect", () => { socket.destroy(); resolve(true); });
|
|
423
|
+
socket.once("error", () => resolve(false));
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Launch msger pointing at the server URL */
|
|
428
|
+
function launchMsger(url: string): void {
|
|
429
|
+
showMessageBox({ url, detach: true, size: { width: 1400, height: 900 } });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function prompt(question: string): Promise<string> {
|
|
433
|
+
const readline = await import("readline");
|
|
434
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
435
|
+
return new Promise(resolve => rl.question(question, (ans: string) => { rl.close(); resolve(ans.trim()); }));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface DiscoveredServer {
|
|
439
|
+
imap: { host: string; port: number; auth: string };
|
|
440
|
+
smtp: { host: string; port: number; auth: string };
|
|
441
|
+
source: string;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
interface AccountInput {
|
|
445
|
+
email: string;
|
|
446
|
+
password?: string;
|
|
447
|
+
imap?: { host: string; port?: number };
|
|
448
|
+
smtp?: { host: string; port?: number };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Check if mailx is configured (has local config or accounts) */
|
|
452
|
+
function hasConfig(): boolean {
|
|
453
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
454
|
+
const mailxDir = path.join(home, ".mailx");
|
|
455
|
+
for (const f of ["config.jsonc", "accounts.jsonc", "settings.jsonc"]) {
|
|
456
|
+
if (fs.existsSync(path.join(mailxDir, f))) return true;
|
|
457
|
+
}
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Try to auto-discover mail server settings from email domain */
|
|
462
|
+
async function autoDiscover(domain: string): Promise<DiscoveredServer | null> {
|
|
463
|
+
// 1. Try Thunderbird ISPDB
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(`https://autoconfig.thunderbird.net/v1.1/${domain}`, { signal: AbortSignal.timeout(5000) });
|
|
466
|
+
if (res.ok) {
|
|
467
|
+
const xml = await res.text();
|
|
468
|
+
const imap = xml.match(/<incomingServer type="imap">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
|
|
469
|
+
const smtp = xml.match(/<outgoingServer type="smtp">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
|
|
470
|
+
if (imap && smtp) {
|
|
471
|
+
return {
|
|
472
|
+
imap: { host: imap[1], port: parseInt(imap[2]), auth: imap[3].includes("OAuth2") ? "oauth2" : "password" },
|
|
473
|
+
smtp: { host: smtp[1], port: parseInt(smtp[2]), auth: smtp[3].includes("OAuth2") ? "oauth2" : "password" },
|
|
474
|
+
source: "ISPDB",
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch { /* timeout or not found */ }
|
|
479
|
+
|
|
480
|
+
// 2. Try DNS SRV records
|
|
481
|
+
try {
|
|
482
|
+
const dns = await import("node:dns/promises");
|
|
483
|
+
const imapSrv = await dns.resolveSrv(`_imaps._tcp.${domain}`).catch((): null => null);
|
|
484
|
+
const smtpSrv = await dns.resolveSrv(`_submission._tcp.${domain}`).catch((): null => null);
|
|
485
|
+
if (imapSrv?.[0] && smtpSrv?.[0]) {
|
|
486
|
+
return {
|
|
487
|
+
imap: { host: imapSrv[0].name, port: imapSrv[0].port, auth: "password" },
|
|
488
|
+
smtp: { host: smtpSrv[0].name, port: smtpSrv[0].port, auth: "password" },
|
|
489
|
+
source: "DNS SRV",
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
} catch { /* DNS failed */ }
|
|
493
|
+
|
|
494
|
+
// 3. Try MX-based detection (Google Workspace, Microsoft 365)
|
|
495
|
+
try {
|
|
496
|
+
const dns = await import("node:dns/promises");
|
|
497
|
+
const records = await dns.resolveMx(domain);
|
|
498
|
+
for (const mx of records) {
|
|
499
|
+
const host = mx.exchange.toLowerCase();
|
|
500
|
+
if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
|
|
501
|
+
return {
|
|
502
|
+
imap: { host: "imap.gmail.com", port: 993, auth: "oauth2" },
|
|
503
|
+
smtp: { host: "smtp.gmail.com", port: 587, auth: "oauth2" },
|
|
504
|
+
source: `MX → Google (${host})`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
|
|
508
|
+
return {
|
|
509
|
+
imap: { host: "outlook.office365.com", port: 993, auth: "oauth2" },
|
|
510
|
+
smtp: { host: "smtp.office365.com", port: 587, auth: "oauth2" },
|
|
511
|
+
source: `MX → Microsoft (${host})`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
} catch { /* DNS failed */ }
|
|
516
|
+
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Prompt user for account details, auto-discover where possible */
|
|
521
|
+
async function promptForAccount(intro?: string): Promise<AccountInput | null> {
|
|
522
|
+
if (intro) console.log(intro);
|
|
523
|
+
const email = await prompt("Email address (or 'skip'): ");
|
|
524
|
+
if (!email || email.toLowerCase() === "skip") return null;
|
|
525
|
+
|
|
526
|
+
const domain = email.split("@")[1]?.toLowerCase() || "";
|
|
527
|
+
const knownOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"];
|
|
528
|
+
|
|
529
|
+
if (knownOAuth.includes(domain)) {
|
|
530
|
+
console.log(` ${domain}: OAuth2 — no password needed. Browser will prompt for authorization.`);
|
|
531
|
+
return { email };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Try auto-discovery
|
|
535
|
+
console.log(` Looking up mail servers for ${domain}...`);
|
|
536
|
+
const discovered = await autoDiscover(domain);
|
|
537
|
+
|
|
538
|
+
if (discovered) {
|
|
539
|
+
console.log(` Found via ${discovered.source}: IMAP ${discovered.imap.host}:${discovered.imap.port}, SMTP ${discovered.smtp.host}:${discovered.smtp.port}`);
|
|
540
|
+
if (discovered.imap.auth === "oauth2") {
|
|
541
|
+
console.log(" OAuth2 authentication — browser will prompt for authorization.");
|
|
542
|
+
return { email };
|
|
543
|
+
}
|
|
544
|
+
const password = await prompt("Password: ");
|
|
545
|
+
return {
|
|
546
|
+
email, password,
|
|
547
|
+
imap: { host: discovered.imap.host, port: discovered.imap.port },
|
|
548
|
+
smtp: { host: discovered.smtp.host, port: discovered.smtp.port },
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Manual fallback
|
|
553
|
+
console.log(" Could not auto-detect servers. Enter manually:");
|
|
554
|
+
const password = await prompt("Password: ");
|
|
555
|
+
const imapHost = await prompt(`IMAP host [imap.${domain}]: `) || `imap.${domain}`;
|
|
556
|
+
const smtpHost = await prompt(`SMTP host [smtp.${domain}]: `) || `smtp.${domain}`;
|
|
557
|
+
return {
|
|
558
|
+
email, password,
|
|
559
|
+
imap: { host: imapHost },
|
|
560
|
+
smtp: { host: smtpHost },
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Interactive first-time setup — GDrive API for cloud storage.
|
|
565
|
+
* Returns true if an account was added (caller should proceed to launch UI),
|
|
566
|
+
* false if the user skipped (UI's in-browser setup form will take over). */
|
|
567
|
+
async function runSetup(providedEmail?: string): Promise<boolean> {
|
|
568
|
+
console.log("\nmailx — first-time setup\n");
|
|
569
|
+
|
|
570
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
571
|
+
const mailxDir = path.join(home, ".mailx");
|
|
572
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
573
|
+
|
|
574
|
+
// Use --email flag or prompt interactively
|
|
575
|
+
const email = providedEmail || await prompt("Email address (Gmail recommended): ");
|
|
576
|
+
if (!email || !email.includes("@")) {
|
|
577
|
+
console.log(`\nNo account added. The UI will show a setup form.`);
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
if (providedEmail) console.log(`Using email: ${email}`);
|
|
581
|
+
|
|
582
|
+
const domain = email.split("@")[1]?.toLowerCase() || "";
|
|
583
|
+
let isGoogle = ["gmail.com", "googlemail.com"].includes(domain);
|
|
584
|
+
if (!isGoogle) {
|
|
585
|
+
try {
|
|
586
|
+
const dnsmod = await import("node:dns/promises");
|
|
587
|
+
const records = await dnsmod.resolveMx(domain);
|
|
588
|
+
isGoogle = records.some(mx => {
|
|
589
|
+
const host = mx.exchange.toLowerCase();
|
|
590
|
+
return host.endsWith(".google.com") || host.endsWith(".googlemail.com");
|
|
591
|
+
});
|
|
592
|
+
if (isGoogle) console.log(` ${domain} is hosted on Google (detected via MX)`);
|
|
593
|
+
} catch { /* DNS lookup failed */ }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// For Google-hosted accounts, check Drive for existing settings first
|
|
597
|
+
let driveFolderId: string | null = null;
|
|
598
|
+
if (isGoogle) {
|
|
599
|
+
console.log("\nChecking Google Drive for existing mailx settings...");
|
|
600
|
+
try {
|
|
601
|
+
const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
602
|
+
driveFolderId = await gDriveFindOrCreateFolder();
|
|
603
|
+
if (driveFolderId) {
|
|
604
|
+
console.log(` Drive folder: My Drive/mailx/ (${driveFolderId})`);
|
|
605
|
+
const gdrive = getCloudProvider("gdrive", driveFolderId);
|
|
606
|
+
if (gdrive) {
|
|
607
|
+
// Read accounts.jsonc (canonical) — ignore legacy settings.jsonc
|
|
608
|
+
const existing = await gdrive.read("accounts.jsonc");
|
|
609
|
+
if (existing) {
|
|
610
|
+
const { parse: parseJsonc } = await import("jsonc-parser");
|
|
611
|
+
const data = parseJsonc(existing);
|
|
612
|
+
const accts = data?.accounts || (Array.isArray(data) ? data : []);
|
|
613
|
+
if (accts.length > 0) {
|
|
614
|
+
console.log(`\nFound ${accts.length} existing account(s) on Google Drive (My Drive/mailx/accounts.jsonc):`);
|
|
615
|
+
for (const a of accts) console.log(` • ${a.label || a.name || a.email}`);
|
|
616
|
+
// Save config pointing to Drive — no prompts needed
|
|
617
|
+
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId: driveFolderId } };
|
|
618
|
+
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
619
|
+
console.log("Local config created. Starting mailx...\n");
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// No existing accounts — save Drive config for later
|
|
625
|
+
const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId: driveFolderId } };
|
|
626
|
+
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
627
|
+
} else {
|
|
628
|
+
console.log(" Could not access Google Drive (OAuth not granted or token expired).");
|
|
629
|
+
console.log(" Account will be saved locally; the UI will retry the cloud sync when you fix Drive access.");
|
|
630
|
+
}
|
|
631
|
+
} catch (e: any) {
|
|
632
|
+
console.log(` Drive check failed: ${e.message} — will save locally and retry from UI.`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// No existing accounts found — build a new account
|
|
637
|
+
const account = { email } as any;
|
|
638
|
+
const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
|
|
639
|
+
if (!isOAuth) {
|
|
640
|
+
account.password = await prompt("Password (app password for Yahoo/AOL/iCloud): ");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Display name: leave empty for Google accounts so MailxService.setupAccount
|
|
644
|
+
// (or the next launch's IMAP auth) can resolve it from the People API once
|
|
645
|
+
// the Gmail OAuth token exists. The Drive token alone doesn't have the
|
|
646
|
+
// contacts.readonly scope, so we can't fetch it here at CLI-prompt time.
|
|
647
|
+
const defaultName = email.split("@")[0];
|
|
648
|
+
const name = await prompt(`Your name (for From: header) [auto-detect from Google, or '${defaultName}']: `) || (isGoogle ? "" : defaultName);
|
|
649
|
+
|
|
650
|
+
// ALWAYS write a local copy first so the data is never lost. The cloud
|
|
651
|
+
// write below is the sync, not the source of truth on this machine.
|
|
652
|
+
const accountsData = { name, accounts: [account] };
|
|
653
|
+
const localAccountsPath = path.join(mailxDir, "accounts.jsonc");
|
|
654
|
+
fs.writeFileSync(localAccountsPath, JSON.stringify(accountsData, null, 2));
|
|
655
|
+
|
|
656
|
+
if (isGoogle && driveFolderId) {
|
|
657
|
+
// Save to Google Drive via API
|
|
658
|
+
console.log("\nSaving account to Google Drive...");
|
|
659
|
+
try {
|
|
660
|
+
const { getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
661
|
+
const gdrive = getCloudProvider("gdrive", driveFolderId);
|
|
662
|
+
if (!gdrive) throw new Error("getCloudProvider returned null");
|
|
663
|
+
await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
|
|
664
|
+
console.log(" Account saved to Google Drive.");
|
|
665
|
+
} catch (e: any) {
|
|
666
|
+
console.log(` Drive write failed: ${e.message}`);
|
|
667
|
+
console.log(` Local copy saved at ${localAccountsPath} — UI will retry cloud sync.`);
|
|
668
|
+
}
|
|
669
|
+
} else if (isGoogle && !driveFolderId) {
|
|
670
|
+
console.log(` Skipping Drive sync (no folder ID). Local copy at ${localAccountsPath}.`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
console.log("Setup complete. Starting mailx...\n");
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Test account connectivity — IMAP connect, SMTP send, sync round-trip */
|
|
678
|
+
async function runTest(): Promise<void> {
|
|
679
|
+
console.log("\nmailx — connection test\n");
|
|
680
|
+
|
|
681
|
+
// Start server in-process to access settings
|
|
682
|
+
console.log("Loading settings...");
|
|
683
|
+
const { loadSettings, getSharedDir, initLocalConfig } = await import("@bobfrankston/mailx-settings");
|
|
684
|
+
initLocalConfig();
|
|
685
|
+
const settings = loadSettings();
|
|
686
|
+
|
|
687
|
+
if (settings.accounts.length === 0) {
|
|
688
|
+
console.log("No accounts configured. Run: mailx -setup");
|
|
689
|
+
process.exit(1);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
console.log(`Shared dir: ${getSharedDir()}`);
|
|
693
|
+
console.log(`Accounts: ${settings.accounts.map((a: any) => `${a.label || a.name} <${a.email}>`).join(", ")}\n`);
|
|
694
|
+
|
|
695
|
+
for (const account of settings.accounts) {
|
|
696
|
+
if (!account.enabled) { console.log(` ${account.label || account.id}: SKIPPED (disabled)\n`); continue; }
|
|
697
|
+
|
|
698
|
+
console.log(`Testing ${account.label || account.id} (${account.email}):`);
|
|
699
|
+
|
|
700
|
+
// Test IMAP
|
|
701
|
+
try {
|
|
702
|
+
const { createAutoImapConfig, CompatImapClient } = await import("@bobfrankston/iflow-direct");
|
|
703
|
+
const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
|
|
704
|
+
const config = createAutoImapConfig({
|
|
705
|
+
server: account.imap.host,
|
|
706
|
+
port: account.imap.port,
|
|
707
|
+
username: account.imap.user,
|
|
708
|
+
password: account.imap.password
|
|
709
|
+
});
|
|
710
|
+
const client = new CompatImapClient(config, () => new NodeTcpTransport());
|
|
711
|
+
const folders = await client.getFolderList();
|
|
712
|
+
await client.logout();
|
|
713
|
+
console.log(` IMAP: OK (${folders.length} folders)`);
|
|
714
|
+
} catch (e: any) {
|
|
715
|
+
console.log(` IMAP: FAILED — ${e.message}`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Test SMTP
|
|
719
|
+
try {
|
|
720
|
+
const { createTransport } = await import("nodemailer");
|
|
721
|
+
let smtpAuth: any;
|
|
722
|
+
if (account.smtp.auth === "password") {
|
|
723
|
+
smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
|
|
724
|
+
} else if (account.smtp.auth === "oauth2") {
|
|
725
|
+
// Try to get OAuth token
|
|
726
|
+
const { createAutoImapConfig } = await import("@bobfrankston/iflow-direct");
|
|
727
|
+
const config = createAutoImapConfig({
|
|
728
|
+
server: account.imap.host,
|
|
729
|
+
port: account.imap.port,
|
|
730
|
+
username: account.imap.user,
|
|
731
|
+
});
|
|
732
|
+
if (config.tokenProvider) {
|
|
733
|
+
const accessToken = await config.tokenProvider();
|
|
734
|
+
smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const transport = createTransport({
|
|
738
|
+
host: account.smtp.host,
|
|
739
|
+
port: account.smtp.port,
|
|
740
|
+
secure: account.smtp.port === 465,
|
|
741
|
+
auth: smtpAuth,
|
|
742
|
+
tls: { rejectUnauthorized: false },
|
|
743
|
+
});
|
|
744
|
+
await transport.verify();
|
|
745
|
+
console.log(` SMTP: OK`);
|
|
746
|
+
|
|
747
|
+
// Send test message to self
|
|
748
|
+
const testSubject = `mailx test — ${new Date().toLocaleString()}`;
|
|
749
|
+
await transport.sendMail({
|
|
750
|
+
from: `${account.name} <${account.email}>`,
|
|
751
|
+
to: account.email,
|
|
752
|
+
subject: testSubject,
|
|
753
|
+
text: `This is a test message from mailx -test.\nSent: ${new Date().toISOString()}\nAccount: ${account.id}`,
|
|
754
|
+
});
|
|
755
|
+
console.log(` SEND: OK — test message sent to ${account.email}`);
|
|
756
|
+
console.log(` Subject: "${testSubject}"`);
|
|
757
|
+
} catch (e: any) {
|
|
758
|
+
console.log(` SMTP: FAILED — ${e.message}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
console.log();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
console.log("Test complete. Check your inbox for the test message(s).");
|
|
765
|
+
process.exit(0);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Register this client on GDrive — writes/updates clients.jsonc with device info.
|
|
769
|
+
* Best-effort: silently skips when cloud isn't ready (fresh install, expired
|
|
770
|
+
* token). The scary "No cloud configured" banner must NOT fire for this. */
|
|
771
|
+
async function registerClient(settings: any): Promise<void> {
|
|
772
|
+
const { cloudRead, cloudWrite, getStorageInfo } = await import("@bobfrankston/mailx-settings");
|
|
773
|
+
// Check if cloud is actually ready before attempting a write — cloudWrite
|
|
774
|
+
// calls setCloudError which pushes an error banner to the UI. We'd rather
|
|
775
|
+
// silently skip than show "No cloud configured" at startup.
|
|
776
|
+
const info = getStorageInfo();
|
|
777
|
+
if (info.mode === "local" || !info.provider || info.provider === "local") return;
|
|
778
|
+
|
|
779
|
+
// Device ID: stable per machine, stored locally
|
|
780
|
+
const deviceIdPath = path.join(os.homedir(), ".mailx", "device-id");
|
|
781
|
+
let deviceId: string;
|
|
782
|
+
if (fs.existsSync(deviceIdPath)) {
|
|
783
|
+
deviceId = fs.readFileSync(deviceIdPath, "utf-8").trim();
|
|
784
|
+
} else {
|
|
785
|
+
deviceId = `${os.hostname()}-${Date.now().toString(36)}`;
|
|
786
|
+
fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true });
|
|
787
|
+
fs.writeFileSync(deviceIdPath, deviceId);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Get local IP
|
|
791
|
+
let localIp = "";
|
|
792
|
+
try {
|
|
793
|
+
const nets = os.networkInterfaces();
|
|
794
|
+
for (const addrs of Object.values(nets) as (os.NetworkInterfaceInfo[] | undefined)[]) {
|
|
795
|
+
for (const addr of addrs || []) {
|
|
796
|
+
if (addr.family === "IPv4" && !addr.internal) { localIp = addr.address; break; }
|
|
797
|
+
}
|
|
798
|
+
if (localIp) break;
|
|
799
|
+
}
|
|
800
|
+
} catch { /* ignore */ }
|
|
801
|
+
|
|
802
|
+
// Read existing clients.jsonc from cloud (may not exist yet — that's fine)
|
|
803
|
+
let clients: Record<string, any> = {};
|
|
804
|
+
try {
|
|
805
|
+
const content = await cloudRead("clients.jsonc");
|
|
806
|
+
if (content) clients = JSON.parse(content);
|
|
807
|
+
} catch { /* start fresh */ }
|
|
808
|
+
|
|
809
|
+
// Update this device's entry
|
|
810
|
+
clients[deviceId] = {
|
|
811
|
+
hostname: os.hostname(),
|
|
812
|
+
platform: `${process.platform} ${process.arch}`,
|
|
813
|
+
accounts: settings.accounts.map((a: any) => a.id),
|
|
814
|
+
lastSeen: new Date().toISOString(),
|
|
815
|
+
ip: localIp,
|
|
816
|
+
version: JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version,
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// Write back. cloudWrite now throws on failure (and sets lastCloudError),
|
|
820
|
+
// so swallow here — registerClient is fire-and-forget from the caller.
|
|
821
|
+
try {
|
|
822
|
+
await cloudWrite("clients.jsonc", JSON.stringify(clients, null, 2));
|
|
823
|
+
console.log(` [client] Registered device: ${deviceId}`);
|
|
824
|
+
} catch (e: any) {
|
|
825
|
+
console.error(` [client] Failed to register device: ${e.message}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function main(): Promise<void> {
|
|
830
|
+
log(`Platform: ${process.platform} ${process.arch}`);
|
|
831
|
+
log(`Node: ${process.version}`);
|
|
832
|
+
log(`Mode: ${setupMode ? "setup" : "auto"}`);
|
|
833
|
+
|
|
834
|
+
// Refuse to run elevated unless explicitly opted in. An elevated mailx
|
|
835
|
+
// can poison %LOCALAPPDATA%\msger\webview2\ (see msger/notes.md WebView2
|
|
836
|
+
// profile playbook) and create admin-owned files under ~/.mailx/ that
|
|
837
|
+
// later non-admin runs can't write to. `net session` requires admin on
|
|
838
|
+
// Windows; succeeds → admin, fails → non-admin. Linux/Mac use process
|
|
839
|
+
// uid (0 = root). --allow-elevated bypasses for scripted admin use.
|
|
840
|
+
if (!hasFlag("allow-elevated") && !isDaemon && isElevated()) {
|
|
841
|
+
const button = await warnElevated();
|
|
842
|
+
if (button !== "Continue anyway") {
|
|
843
|
+
log("User chose Quit on elevated-run warning. Exiting.");
|
|
844
|
+
process.exit(0);
|
|
845
|
+
}
|
|
846
|
+
log("User chose Continue anyway on elevated-run warning. Proceeding (will likely poison local state).");
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Test connectivity
|
|
850
|
+
if (testMode) {
|
|
851
|
+
await runTest();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Add account to existing config
|
|
856
|
+
if (addMode) {
|
|
857
|
+
const account = await promptForAccount();
|
|
858
|
+
if (account) {
|
|
859
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
860
|
+
const mailxDir = path.join(home, ".mailx");
|
|
861
|
+
const settingsPath = path.join(mailxDir, "settings.jsonc");
|
|
862
|
+
let settings: any;
|
|
863
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8").replace(/\r/g, "").replace(/\/\/.*/g, "")); } catch { settings = { accounts: [] }; }
|
|
864
|
+
if (!settings.accounts) settings.accounts = [];
|
|
865
|
+
settings.accounts.push(account);
|
|
866
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
867
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
868
|
+
console.log(`Added ${account.email} to settings. Restart mailx to connect.`);
|
|
869
|
+
}
|
|
870
|
+
process.exit(0);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Auto-detect first run — enter setup if no config exists.
|
|
874
|
+
// Skip CLI prompts entirely when stdin isn't a TTY (auto-detached daemon
|
|
875
|
+
// has stdio:"ignore", so prompt() returns "" instantly and the user never
|
|
876
|
+
// gets to type their email — silent no-setup). The in-browser setup form
|
|
877
|
+
// takes over in that case.
|
|
878
|
+
const hasTty = setupMode ? !!process.stdin.isTTY : (process.stdin.isTTY === true);
|
|
879
|
+
if (setupMode || !hasConfig()) {
|
|
880
|
+
if (!setupMode) console.log("No mailx configuration found.");
|
|
881
|
+
// -email or -mail flag skips the interactive prompt
|
|
882
|
+
const emailFlag = args.findIndex(a => a === "-email" || a === "--email" || a === "-mail" || a === "--mail");
|
|
883
|
+
const emailArg = args.find(a => a.startsWith("-email=") || a.startsWith("--email=") || a.startsWith("-mail=") || a.startsWith("--mail="))?.split("=")[1]
|
|
884
|
+
|| (emailFlag >= 0 ? args[emailFlag + 1] : undefined);
|
|
885
|
+
if (hasTty || emailArg) {
|
|
886
|
+
await runSetup(emailArg);
|
|
887
|
+
} else {
|
|
888
|
+
console.log("No TTY and no -email flag — skipping CLI setup; in-browser setup form will appear.");
|
|
889
|
+
// Ensure the data dir exists so the UI can write its config.
|
|
890
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
891
|
+
fs.mkdirSync(path.join(home, ".mailx"), { recursive: true });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Redirect console to log file — keep terminal clean
|
|
896
|
+
if (!verbose) {
|
|
897
|
+
const home = process.env.USERPROFILE || process.env.HOME || ".";
|
|
898
|
+
const logDir = path.join(home, ".mailx", "logs");
|
|
899
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
900
|
+
// Prune logs older than LOG_RETENTION_DAYS on startup. Keep it simple:
|
|
901
|
+
// scan the dir, stat, delete. Cheap even with years of history.
|
|
902
|
+
// Bumped 7 → 30 days so "where did my letter go?" reports can still
|
|
903
|
+
// reach the `[reconcile-delete]` log entry weeks after the fact.
|
|
904
|
+
const LOG_RETENTION_DAYS = 30;
|
|
905
|
+
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
|
906
|
+
try {
|
|
907
|
+
for (const name of fs.readdirSync(logDir)) {
|
|
908
|
+
if (!/^mailx-\d{4}-\d{2}-\d{2}\.log$/.test(name)) continue;
|
|
909
|
+
const full = path.join(logDir, name);
|
|
910
|
+
try {
|
|
911
|
+
const st = fs.statSync(full);
|
|
912
|
+
if (st.mtimeMs < cutoff) fs.unlinkSync(full);
|
|
913
|
+
} catch { /* ignore per-file error */ }
|
|
914
|
+
}
|
|
915
|
+
} catch { /* ignore — log pruning is best-effort */ }
|
|
916
|
+
const logDate = new Date().toISOString().slice(0, 10);
|
|
917
|
+
const logPath = path.join(logDir, `mailx-${logDate}.log`);
|
|
918
|
+
const logStream = fs.createWriteStream(logPath, { flags: "a" });
|
|
919
|
+
const ts = () => new Date().toISOString().slice(11, 23);
|
|
920
|
+
console.log = (...a: any[]) => { logStream.write(`${ts()} ${a.join(" ")}\n`); };
|
|
921
|
+
console.error = (...a: any[]) => { logStream.write(`${ts()} ERROR ${a.join(" ")}\n`); };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// IPC service mode — no HTTP server
|
|
925
|
+
console.log("Starting mailx service...");
|
|
926
|
+
const { MailxDB } = await import("@bobfrankston/mailx-store");
|
|
927
|
+
const { ImapManager } = await import("@bobfrankston/mailx-imap");
|
|
928
|
+
const { MailxService } = await import("@bobfrankston/mailx-service");
|
|
929
|
+
const { dispatch } = await import("@bobfrankston/mailx-service/jsonrpc.js");
|
|
930
|
+
const { loadSettings, loadAccountsAsync, getConfigDir, getStorageInfo } = await import("@bobfrankston/mailx-settings");
|
|
931
|
+
|
|
932
|
+
let settings = loadSettings();
|
|
933
|
+
if (settings.accounts.length === 0) {
|
|
934
|
+
const cloudAccounts = await loadAccountsAsync();
|
|
935
|
+
if (cloudAccounts.length > 0) {
|
|
936
|
+
settings = { ...settings, accounts: cloudAccounts };
|
|
937
|
+
console.log(` Loaded ${cloudAccounts.length} account(s) from cloud`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const db = new MailxDB(getConfigDir());
|
|
942
|
+
|
|
943
|
+
// Auto-create the sending/ recovery README on every startup. Stays in
|
|
944
|
+
// sync with the running version of mailx; user can ignore once the
|
|
945
|
+
// disk-staging fallback is no longer needed.
|
|
946
|
+
try {
|
|
947
|
+
const sendingDir = path.join(getConfigDir(), "sending");
|
|
948
|
+
fs.mkdirSync(sendingDir, { recursive: true });
|
|
949
|
+
const readmePath = path.join(sendingDir, "README.md");
|
|
950
|
+
const readmeBody = `# \`~/.mailx/sending/\` and \`~/.mailx/outbox/\` — outgoing-mail staging
|
|
951
|
+
|
|
952
|
+
Auto-generated by mailx on startup. Manual recovery reference for when mailx is broken or you need to feed an outgoing message into another mail program.
|
|
953
|
+
|
|
954
|
+
## Layout
|
|
955
|
+
|
|
956
|
+
\`\`\`
|
|
957
|
+
~/.mailx/
|
|
958
|
+
├── outbox/<account>/
|
|
959
|
+
│ └── *.ltr ← THE QUEUE. Worker scans every 10s, sends, deletes on success.
|
|
960
|
+
└── sending/<account>/
|
|
961
|
+
├── editing/ ← Last 3 draft autosaves while composing.
|
|
962
|
+
├── queued/ ← Manual drop-in / crash-recovery copies.
|
|
963
|
+
└── sent/ ← Audit trail of successfully sent messages.
|
|
964
|
+
\`\`\`
|
|
965
|
+
|
|
966
|
+
In-flight files are atomically renamed to \`<file>.sending-<host>-<pid>\` while the worker is processing them — same-machine claim so two mailx instances don't double-send. Stale claims (dead PIDs on this host) are recovered on the next tick.
|
|
967
|
+
|
|
968
|
+
## Manual fallback
|
|
969
|
+
|
|
970
|
+
- **mailx is dead, need to send a draft** — most recent file in \`sending/<account>/editing/\` is a complete RFC 822 message; copy the body into another mail client and resend.
|
|
971
|
+
- **Feed a raw .eml to mailx** — drop into \`sending/<account>/queued/\`. Picked up within 10s.
|
|
972
|
+
- **mailx says queued but server doesn't have it** — look in \`outbox/<account>/\`. \`.ltr\` still there → worker hasn't sent yet (check \`~/.mailx/logs/\`). \`.sending-<host>-<pid>\` → in flight. Gone → success.
|
|
973
|
+
|
|
974
|
+
## Format
|
|
975
|
+
|
|
976
|
+
RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable in a text editor). Every message carries \`Message-ID:\` for cross-device dedup; \`X-Mailx-Retry\` marks retry attempts.
|
|
977
|
+
`;
|
|
978
|
+
// Only rewrite if content drifted (avoids gratuitous mtime updates).
|
|
979
|
+
let existing = "";
|
|
980
|
+
try { existing = fs.readFileSync(readmePath, "utf-8"); } catch { /* missing */ }
|
|
981
|
+
if (existing !== readmeBody) fs.writeFileSync(readmePath, readmeBody);
|
|
982
|
+
} catch (e: any) {
|
|
983
|
+
console.error(` [readme] Could not write sending README: ${e?.message || e}`);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// `--server` mode — hand off to the Express HTTP server package (self-
|
|
987
|
+
// contained; initializes its own DB / IMAP manager / MailxService). No
|
|
988
|
+
// WebView2, no IPC bridge. Useful for remote access (phone before MAUI
|
|
989
|
+
// was a thing) and for JSON-RPC debugging from devtools. Loopback by
|
|
990
|
+
// default; passes --external through if the user asked for it. See C34.
|
|
991
|
+
if (hasFlag("server")) {
|
|
992
|
+
writeInstanceFile(process.pid);
|
|
993
|
+
await import("@bobfrankston/mailx-server");
|
|
994
|
+
// mailx-server's index.ts self-invokes `start()` — once imported,
|
|
995
|
+
// Express owns the event loop. Don't fall through to showService.
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
|
|
1000
|
+
const imapManager = new ImapManager(db, () => new NodeTcpTransport());
|
|
1001
|
+
// Native client is the only option (iflow-direct)
|
|
1002
|
+
const svc = new MailxService(db, imapManager);
|
|
1003
|
+
|
|
1004
|
+
// Open msger in service mode — custom protocol serves files from client dir
|
|
1005
|
+
const clientDir = path.join(import.meta.dirname, "..", "client");
|
|
1006
|
+
const mailxapiPath = path.join(clientDir, "lib", "mailxapi.js");
|
|
1007
|
+
const mailxapiScript = fs.readFileSync(mailxapiPath, "utf-8");
|
|
1008
|
+
|
|
1009
|
+
// Restore saved window geometry (position + size) from previous session
|
|
1010
|
+
const windowJsonPath = path.join(getConfigDir(), "window.json");
|
|
1011
|
+
interface WindowGeometry { x: number; y: number; width: number; height: number }
|
|
1012
|
+
let savedGeometry: WindowGeometry | null = null;
|
|
1013
|
+
try {
|
|
1014
|
+
const raw = JSON.parse(fs.readFileSync(windowJsonPath, "utf-8"));
|
|
1015
|
+
if (typeof raw.width === "number" && raw.width > 200 &&
|
|
1016
|
+
typeof raw.height === "number" && raw.height > 200) {
|
|
1017
|
+
savedGeometry = raw as WindowGeometry;
|
|
1018
|
+
}
|
|
1019
|
+
} catch { /* no saved geometry — use defaults */ }
|
|
1020
|
+
|
|
1021
|
+
const rootPkgVersion = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version;
|
|
1022
|
+
// Pass the .png as the window-decode icon: msger uses the `image` crate
|
|
1023
|
+
// to decode `options.icon` for the Tao window icon, and PNG-in-ICO files
|
|
1024
|
+
// round-trip unreliably through the image-0.24 ICO decoder, which leaves
|
|
1025
|
+
// the taskbar entry showing the empty msgernative.exe default. PNG always
|
|
1026
|
+
// decodes cleanly. The .ico path is forwarded separately as
|
|
1027
|
+
// `relaunchIcon`, which msger uses for `PKEY_AppUserModel_RelaunchIconResource`
|
|
1028
|
+
// (the path is consumed verbatim — no decode — so PNG-in-ICO is fine
|
|
1029
|
+
// there). Falls back if either file is missing.
|
|
1030
|
+
const __iconIco = path.join(clientDir, "icon.ico");
|
|
1031
|
+
const __iconPng = path.join(clientDir, "icon.png");
|
|
1032
|
+
const __iconPathRuntime = fs.existsSync(__iconPng) ? __iconPng : __iconIco;
|
|
1033
|
+
const __iconPathPin = fs.existsSync(__iconIco) ? __iconIco : undefined;
|
|
1034
|
+
// Pinned-shortcut launch command. Windows captures the running exe at
|
|
1035
|
+
// pin time by default, but the running exe is `mailx.exe` — actually
|
|
1036
|
+
// msgernative.exe expecting JSON options on stdin. Click that bare and
|
|
1037
|
+
// it dies on `serde_json::from_str("")`. Override with an explicit
|
|
1038
|
+
// node + script invocation so the pin re-enters through bin/mailx.js
|
|
1039
|
+
// the same way the `mailx` CLI does.
|
|
1040
|
+
const __mailxJs = path.join(import.meta.dirname, "mailx.js");
|
|
1041
|
+
const __relaunchCommand = `"${process.execPath}" "${__mailxJs}"`;
|
|
1042
|
+
const handle = showService({
|
|
1043
|
+
title: `mailx v${rootPkgVersion}`,
|
|
1044
|
+
url: "index.html",
|
|
1045
|
+
contentDir: clientDir,
|
|
1046
|
+
initScript: mailxapiScript,
|
|
1047
|
+
icon: __iconPathRuntime,
|
|
1048
|
+
relaunchIcon: __iconPathPin,
|
|
1049
|
+
relaunchCommand: __relaunchCommand,
|
|
1050
|
+
relaunchDisplayName: "mailx",
|
|
1051
|
+
aumid: "com.frankston.mailx",
|
|
1052
|
+
size: savedGeometry
|
|
1053
|
+
? { width: savedGeometry.width, height: savedGeometry.height }
|
|
1054
|
+
: { width: 1400, height: 900 },
|
|
1055
|
+
pos: savedGeometry
|
|
1056
|
+
? { x: savedGeometry.x, y: savedGeometry.y }
|
|
1057
|
+
: undefined,
|
|
1058
|
+
escapeCloses: false,
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Register ourselves as the live instance so subsequent `mailx` invocations
|
|
1062
|
+
// can detect version-mismatch and upgrade us (see top of file). Clear on
|
|
1063
|
+
// any of: SIGINT, SIGTERM, normal exit.
|
|
1064
|
+
//
|
|
1065
|
+
// Critical: the SIGTERM handler must *close the WebView child process*
|
|
1066
|
+
// (handle.close() → kills msgernative.exe) before Node exits. Without
|
|
1067
|
+
// this, the auto-upgrade leaves the old WebView orphaned on screen and
|
|
1068
|
+
// the user sees an apparently frozen "old mailx" while the new Node is
|
|
1069
|
+
// trying to spawn a second one. Cascade-killing the child makes the
|
|
1070
|
+
// version-mismatch auto-upgrade actually transparent to the user.
|
|
1071
|
+
writeInstanceFile(process.pid);
|
|
1072
|
+
const __cleanupInstance = () => {
|
|
1073
|
+
// Only clear if WE are still the registered instance. Prevents the
|
|
1074
|
+
// restart-daemon sequence (clear → spawn → new daemon writes its
|
|
1075
|
+
// own entry → we exit) from deleting the replacement's claim on
|
|
1076
|
+
// the way out.
|
|
1077
|
+
const inst = readInstanceFile();
|
|
1078
|
+
if (inst && inst.pid === process.pid) clearInstanceFile();
|
|
1079
|
+
try { handle.close(); } catch { /* already gone */ }
|
|
1080
|
+
};
|
|
1081
|
+
process.once("exit", __cleanupInstance);
|
|
1082
|
+
process.once("SIGINT", () => { __cleanupInstance(); process.exit(0); });
|
|
1083
|
+
process.once("SIGTERM", () => { __cleanupInstance(); process.exit(0); });
|
|
1084
|
+
|
|
1085
|
+
// Handle requests from WebView → dispatch to MailxService
|
|
1086
|
+
// Pass server version to dispatch so getVersion returns it
|
|
1087
|
+
const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
|
|
1088
|
+
|
|
1089
|
+
handle.onRequest(async (req) => {
|
|
1090
|
+
if (!req._action) {
|
|
1091
|
+
// msger sends {"button":"OK","closed":true} on navigation — ignore it, don't exit
|
|
1092
|
+
if (req.closed || req.button) {
|
|
1093
|
+
console.log(`[ipc] ← ignored close signal during navigation: ${JSON.stringify(req).substring(0, 100)}`);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
console.log(`[ipc] ← ignored (no _action): ${JSON.stringify(req).substring(0, 100)}`);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
console.log(`[ipc] ← ${req._action} (${req._cbid})`);
|
|
1100
|
+
// Save window position+size for next launch (handled here, not in MailxService)
|
|
1101
|
+
if (req._action === "saveWindowGeometry") {
|
|
1102
|
+
try {
|
|
1103
|
+
const geom: WindowGeometry = {
|
|
1104
|
+
x: Number(req.x) || 0,
|
|
1105
|
+
y: Number(req.y) || 0,
|
|
1106
|
+
width: Math.max(400, Number(req.width) || 1400),
|
|
1107
|
+
height: Math.max(300, Number(req.height) || 900),
|
|
1108
|
+
};
|
|
1109
|
+
fs.writeFileSync(windowJsonPath, JSON.stringify(geom, null, 2));
|
|
1110
|
+
} catch (e: any) {
|
|
1111
|
+
console.error(`[window] Failed to save geometry: ${e.message}`);
|
|
1112
|
+
}
|
|
1113
|
+
handle.send({ _cbid: req._cbid, result: { ok: true } });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
// Restart the daemon in-place without npm install. Subtle: the new
|
|
1117
|
+
// mailx's startup-time instance check sees the instance.json we
|
|
1118
|
+
// wrote and bails with "already running" if versions match —
|
|
1119
|
+
// skipping the new process entirely. Clear the instance file
|
|
1120
|
+
// FIRST so the replacement can claim the slot, THEN spawn, THEN
|
|
1121
|
+
// gracefully shut this process down. The exit handler guards
|
|
1122
|
+
// against clobbering the replacement's entry (see __cleanupInstance
|
|
1123
|
+
// below — only clears if instance.json's PID still matches ours).
|
|
1124
|
+
if (req._action === "restartDaemon") {
|
|
1125
|
+
handle.send({ _cbid: req._cbid, ok: true, status: "restarting" });
|
|
1126
|
+
try {
|
|
1127
|
+
clearInstanceFile();
|
|
1128
|
+
const { spawn: spawnChild } = await import("child_process");
|
|
1129
|
+
const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
|
|
1130
|
+
child.unref();
|
|
1131
|
+
console.log(" [restart] Spawned fresh daemon; shutting down current");
|
|
1132
|
+
// Give the spawn a moment to take hold before we start
|
|
1133
|
+
// tearing things down — otherwise IMAP disconnects could
|
|
1134
|
+
// race with the new process's startup handshake.
|
|
1135
|
+
await new Promise(r => setTimeout(r, 800));
|
|
1136
|
+
} catch (e: any) {
|
|
1137
|
+
console.error(` [restart] Spawn failed: ${e.message}`);
|
|
1138
|
+
}
|
|
1139
|
+
gracefulShutdown("User requested restart");
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
// Auto-update action: run npm install then restart
|
|
1143
|
+
if (req._action === "performUpdate") {
|
|
1144
|
+
handle.send({ _cbid: req._cbid, ok: true, status: "updating" });
|
|
1145
|
+
try {
|
|
1146
|
+
const { execSync, spawn: spawnChild } = await import("child_process");
|
|
1147
|
+
console.log(" [update] Installing latest version...");
|
|
1148
|
+
execSync("npm install -g @bobfrankston/mailx", { encoding: "utf-8", timeout: 120_000, stdio: "inherit", windowsHide: true });
|
|
1149
|
+
console.log(" [update] Install complete — relaunching");
|
|
1150
|
+
// Spawn the new version detached so it outlives this process
|
|
1151
|
+
const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
|
|
1152
|
+
child.unref();
|
|
1153
|
+
} catch (e: any) {
|
|
1154
|
+
console.error(` [update] Install failed: ${e.message}`);
|
|
1155
|
+
}
|
|
1156
|
+
gracefulShutdown("Update applied");
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
// Per-action wall-clock timing so a "took N seconds" report tells us
|
|
1160
|
+
// where between Rust→stdin→dispatch→service the time actually went.
|
|
1161
|
+
const ipcT0 = Date.now();
|
|
1162
|
+
try {
|
|
1163
|
+
const response = await dispatch(svc as any, req);
|
|
1164
|
+
const elapsed = Date.now() - ipcT0;
|
|
1165
|
+
console.log(`[ipc] → ${req._action} (${req._cbid}) ok in ${elapsed}ms`);
|
|
1166
|
+
handle.send(response);
|
|
1167
|
+
} catch (e: any) {
|
|
1168
|
+
const elapsed = Date.now() - ipcT0;
|
|
1169
|
+
console.error(`[ipc] → ${req._action} (${req._cbid}) error in ${elapsed}ms: ${e.message}`);
|
|
1170
|
+
handle.send({ _cbid: req._cbid, error: e.message });
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
// Wire IMAP events → push to WebView (throttled to avoid flooding stdin)
|
|
1175
|
+
let pendingSyncProgress: Record<string, { phase: string; progress: number }> = {};
|
|
1176
|
+
let syncProgressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1177
|
+
imapManager.on("syncProgress", (accountId: string, phase: string, progress: number) => {
|
|
1178
|
+
pendingSyncProgress[accountId] = { phase, progress };
|
|
1179
|
+
if (!syncProgressTimer) {
|
|
1180
|
+
syncProgressTimer = setTimeout(() => {
|
|
1181
|
+
syncProgressTimer = null;
|
|
1182
|
+
for (const [id, p] of Object.entries(pendingSyncProgress)) {
|
|
1183
|
+
handle.send({ _event: "syncProgress", type: "syncProgress", accountId: id, phase: p.phase, progress: p.progress });
|
|
1184
|
+
}
|
|
1185
|
+
pendingSyncProgress = {};
|
|
1186
|
+
}, 500); // batch sync events every 500ms
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
let pendingCounts: Record<string, any> = {};
|
|
1190
|
+
let countsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1191
|
+
imapManager.on("folderCountsChanged", (accountId: string, counts: any) => {
|
|
1192
|
+
pendingCounts[accountId] = counts;
|
|
1193
|
+
if (!countsTimer) {
|
|
1194
|
+
countsTimer = setTimeout(() => {
|
|
1195
|
+
countsTimer = null;
|
|
1196
|
+
for (const [id, c] of Object.entries(pendingCounts)) {
|
|
1197
|
+
handle.send({ _event: "folderCountsChanged", type: "folderCountsChanged", accountId: id, ...c });
|
|
1198
|
+
}
|
|
1199
|
+
pendingCounts = {};
|
|
1200
|
+
}, 1000); // batch count updates every 1s
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
// Batch folderSynced events (same cadence as counts) so a sync-all burst
|
|
1204
|
+
// doesn't flood the WebView with one stdin write per folder.
|
|
1205
|
+
let pendingSynced: Record<string, { folderId: number; syncedAt: number }[]> = {};
|
|
1206
|
+
let syncedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1207
|
+
imapManager.on("folderSynced", (accountId: string, folderId: number, syncedAt: number) => {
|
|
1208
|
+
(pendingSynced[accountId] ||= []).push({ folderId, syncedAt });
|
|
1209
|
+
if (!syncedTimer) {
|
|
1210
|
+
syncedTimer = setTimeout(() => {
|
|
1211
|
+
syncedTimer = null;
|
|
1212
|
+
for (const [id, entries] of Object.entries(pendingSynced)) {
|
|
1213
|
+
handle.send({ _event: "folderSynced", type: "folderSynced", accountId: id, entries });
|
|
1214
|
+
}
|
|
1215
|
+
pendingSynced = {};
|
|
1216
|
+
}, 1000);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
imapManager.on("syncError", (accountId: string, error: string) => {
|
|
1220
|
+
handle.send({ _event: "error", type: "error", message: `${accountId}: ${error}` });
|
|
1221
|
+
});
|
|
1222
|
+
imapManager.on("accountError", (accountId: string, error: string, hint: string, isOAuth: boolean) => {
|
|
1223
|
+
handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
|
|
1224
|
+
});
|
|
1225
|
+
imapManager.on("configChanged", (filename: string) => {
|
|
1226
|
+
handle.send({ _event: "configChanged", type: "configChanged", filename });
|
|
1227
|
+
});
|
|
1228
|
+
imapManager.on("outboxStatus", (status: any) => {
|
|
1229
|
+
handle.send({ _event: "outboxStatus", type: "outboxStatus", ...status });
|
|
1230
|
+
});
|
|
1231
|
+
// syncComplete drives the folder-tree refresh that picks up newly-discovered
|
|
1232
|
+
// folders on first run (Gmail accounts have no folders in the DB until the
|
|
1233
|
+
// first sync fetches the labels). Without this forward, the UI shows the
|
|
1234
|
+
// account but no folders, and never auto-selects the inbox.
|
|
1235
|
+
imapManager.on("syncComplete", (accountId: string) => {
|
|
1236
|
+
handle.send({ _event: "syncComplete", type: "syncComplete", accountId });
|
|
1237
|
+
});
|
|
1238
|
+
imapManager.on("syncActionFailed", (accountId: string, action: string, uid: number, error: string) => {
|
|
1239
|
+
handle.send({ _event: "syncActionFailed", type: "syncActionFailed", accountId, action, uid, error });
|
|
1240
|
+
});
|
|
1241
|
+
// External-edit (Word) save events. The service watches the temp file
|
|
1242
|
+
// and emits this when Word writes; compose.ts listens and reloads Quill.
|
|
1243
|
+
imapManager.on("wordEditUpdated", (payload: { editId: string; html: string }) => {
|
|
1244
|
+
handle.send({ _event: "wordEditUpdated", type: "wordEditUpdated", ...payload });
|
|
1245
|
+
});
|
|
1246
|
+
// Cloud-write/read failures from mailx-settings → push to UI as a banner so
|
|
1247
|
+
// silent fall-back-to-local can no longer swallow Drive errors.
|
|
1248
|
+
const { onCloudError } = await import("@bobfrankston/mailx-settings");
|
|
1249
|
+
onCloudError((error, ctx) => {
|
|
1250
|
+
if (error) {
|
|
1251
|
+
handle.send({ _event: "cloudError", type: "cloudError", error, op: ctx?.op, filename: ctx?.filename });
|
|
1252
|
+
} else {
|
|
1253
|
+
handle.send({ _event: "cloudError", type: "cloudError", error: null });
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
// Coalesce bodyCached into batches so a prefetch storm doesn't flood stdin
|
|
1257
|
+
// with one IPC write per message — lets the UI flip many rows at once.
|
|
1258
|
+
let pendingCached: { accountId: string; uid: number }[] = [];
|
|
1259
|
+
let cachedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1260
|
+
// Calendar/tasks refresh completion — service emits when a pull merged
|
|
1261
|
+
// new rows or reconciled a server-side delete. Client re-renders its
|
|
1262
|
+
// sidebar on receipt. No payload beyond accountId.
|
|
1263
|
+
imapManager.on("calendarUpdated", (payload: any) => {
|
|
1264
|
+
handle.send({ _event: "calendarUpdated", type: "calendarUpdated", ...payload });
|
|
1265
|
+
});
|
|
1266
|
+
imapManager.on("tasksUpdated", (payload: any) => {
|
|
1267
|
+
handle.send({ _event: "tasksUpdated", type: "tasksUpdated", ...payload });
|
|
1268
|
+
});
|
|
1269
|
+
imapManager.on("authScopeError", (payload: any) => {
|
|
1270
|
+
handle.send({ _event: "authScopeError", type: "authScopeError", ...payload });
|
|
1271
|
+
});
|
|
1272
|
+
imapManager.on("bodyCached", (accountId: string, uid: number) => {
|
|
1273
|
+
pendingCached.push({ accountId, uid });
|
|
1274
|
+
if (!cachedTimer) {
|
|
1275
|
+
cachedTimer = setTimeout(() => {
|
|
1276
|
+
cachedTimer = null;
|
|
1277
|
+
const batch = pendingCached;
|
|
1278
|
+
pendingCached = [];
|
|
1279
|
+
handle.send({ _event: "bodyCached", type: "bodyCached", items: batch });
|
|
1280
|
+
}, 500);
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// Brief pause for WebView2 to initialize before starting IMAP (avoids stdin writes during init)
|
|
1285
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1286
|
+
|
|
1287
|
+
// Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
|
|
1288
|
+
for (const account of settings.accounts) {
|
|
1289
|
+
if (!account.enabled) continue;
|
|
1290
|
+
try {
|
|
1291
|
+
await imapManager.addAccount(account);
|
|
1292
|
+
console.log(` Account: ${account.label || account.name} (${account.id})`);
|
|
1293
|
+
} catch (e: any) {
|
|
1294
|
+
console.error(` Failed: ${account.id}: ${e.message}`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
// After OAuth has completed, resolve missing display names for Google
|
|
1298
|
+
// accounts via the People API (contacts.readonly is in the Gmail scope).
|
|
1299
|
+
// "Missing" = empty or matches the email local-part default.
|
|
1300
|
+
try {
|
|
1301
|
+
const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
|
|
1302
|
+
const { saveAccounts } = await import("@bobfrankston/mailx-settings");
|
|
1303
|
+
let updated = false;
|
|
1304
|
+
for (const acct of settings.accounts) {
|
|
1305
|
+
if (!acct.enabled) continue;
|
|
1306
|
+
const isGoogle = acct.email.endsWith("@gmail.com")
|
|
1307
|
+
|| acct.email.endsWith("@googlemail.com")
|
|
1308
|
+
|| acct.imap?.host?.includes("gmail");
|
|
1309
|
+
if (!isGoogle) continue;
|
|
1310
|
+
const local = acct.email.split("@")[0];
|
|
1311
|
+
const looksDefault = !acct.name || acct.name === local;
|
|
1312
|
+
if (!looksDefault) continue;
|
|
1313
|
+
try {
|
|
1314
|
+
const tok = await imapManager.getOAuthToken(acct.id);
|
|
1315
|
+
if (!tok) continue;
|
|
1316
|
+
const profile = await getGoogleProfile(tok);
|
|
1317
|
+
if (profile?.name && profile.name !== acct.name) {
|
|
1318
|
+
console.log(` [name-resolve] ${acct.id}: '${acct.name || "(empty)"}' → '${profile.name}'`);
|
|
1319
|
+
acct.name = profile.name;
|
|
1320
|
+
updated = true;
|
|
1321
|
+
}
|
|
1322
|
+
} catch (e: any) {
|
|
1323
|
+
console.error(` [name-resolve] ${acct.id}: ${e.message}`);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (updated) {
|
|
1327
|
+
try { await saveAccounts(settings.accounts); } catch (e: any) {
|
|
1328
|
+
console.error(` [name-resolve] saveAccounts failed: ${e.message}`);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
} catch (e: any) {
|
|
1332
|
+
console.error(` [name-resolve] init failed: ${e.message}`);
|
|
1333
|
+
}
|
|
1334
|
+
// Register this client device on GDrive (fire-and-forget).
|
|
1335
|
+
// Skip when no accounts — cloud isn't configured yet on fresh installs,
|
|
1336
|
+
// so the write fails with "No cloud configured" and flashes a scary banner.
|
|
1337
|
+
if (settings.accounts.length > 0) {
|
|
1338
|
+
registerClient(settings).catch(() => {});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Start sync in background — don't block. Kick off IDLE watchers once the
|
|
1342
|
+
// initial sync finishes so IMAP accounts get instant-push new-mail (the
|
|
1343
|
+
// 5-min STATUS poll is only a safety net).
|
|
1344
|
+
if (settings.accounts.some(a => a.enabled)) {
|
|
1345
|
+
// Fast-path: fire a quick INBOX check on every account IMMEDIATELY,
|
|
1346
|
+
// parallel to the full syncAll. quickInboxCheckAccount uses a fresh
|
|
1347
|
+
// client + a cached folder list from the DB, so it skips the
|
|
1348
|
+
// folder-list fetch that syncAll's step 1 does. On a cold Dovecot
|
|
1349
|
+
// session that folder LIST can take several seconds on big trees
|
|
1350
|
+
// (bobma = ~105 folders) — no reason the user should wait for it
|
|
1351
|
+
// before seeing mail that arrived overnight in INBOX.
|
|
1352
|
+
for (const acct of settings.accounts) {
|
|
1353
|
+
if (!acct.enabled) continue;
|
|
1354
|
+
imapManager.quickInboxCheckAccount(acct.id).catch(e =>
|
|
1355
|
+
console.error(` [startup-check] ${acct.id}: ${e?.message || e}`)
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
imapManager.syncAll()
|
|
1359
|
+
.then(() => imapManager.startWatching())
|
|
1360
|
+
.then(() => {
|
|
1361
|
+
// Seed the contacts table from received messages so address
|
|
1362
|
+
// autocomplete works on the first compose without waiting for
|
|
1363
|
+
// the user to manually trigger it. Cheap — one grouped SELECT
|
|
1364
|
+
// + one INSERT per new sender. No-op if contact already exists.
|
|
1365
|
+
const added = db.seedContactsFromMessages();
|
|
1366
|
+
if (added > 0) console.log(` [contacts] seeded ${added} from message senders`);
|
|
1367
|
+
})
|
|
1368
|
+
.catch(e => console.error(` Sync error: ${e.message}`));
|
|
1369
|
+
}
|
|
1370
|
+
imapManager.startPeriodicSync(settings.sync.intervalMinutes);
|
|
1371
|
+
imapManager.startOutboxWorker();
|
|
1372
|
+
imapManager.watchConfigFiles();
|
|
1373
|
+
// Re-seed contacts every 30 min so newly-received senders surface in
|
|
1374
|
+
// autocomplete without restarting mailx. Cheap; idempotent.
|
|
1375
|
+
setInterval(() => {
|
|
1376
|
+
try {
|
|
1377
|
+
const added = db.seedContactsFromMessages();
|
|
1378
|
+
if (added > 0) console.log(` [contacts] periodic seed added ${added} new senders`);
|
|
1379
|
+
} catch (e: any) { console.error(` [contacts] periodic seed error: ${e.message}`); }
|
|
1380
|
+
}, 30 * 60_000);
|
|
1381
|
+
// Drain store_sync (calendar / tasks / contacts two-way pushes) every
|
|
1382
|
+
// 30s. Local edits also drain immediately; this picks up rows that
|
|
1383
|
+
// failed their first attempt (network blip, token refresh, 5xx).
|
|
1384
|
+
setInterval(() => {
|
|
1385
|
+
svc.drainStoreSync().catch((e: any) =>
|
|
1386
|
+
console.error(` [store_sync] periodic drain error: ${e?.message || e}`));
|
|
1387
|
+
}, 30_000);
|
|
1388
|
+
// Calendar + Tasks poll — pulls server-side changes so the sidebar
|
|
1389
|
+
// reflects events added/edited/deleted on another device without
|
|
1390
|
+
// needing a sidebar nav click. 5-minute cadence is well under the
|
|
1391
|
+
// per-user rate limit and the 1M/day project quota. Webhooks would
|
|
1392
|
+
// be cheaper in theory but need a public HTTPS endpoint; poll is
|
|
1393
|
+
// the pragmatic choice (confirmed 2026-04-23). getCalendarEvents /
|
|
1394
|
+
// getTasks emit the refresh event via imapManager, so the sidebar
|
|
1395
|
+
// re-renders automatically.
|
|
1396
|
+
const CAL_POLL_MS = 5 * 60_000;
|
|
1397
|
+
const horizonDays = 90; // larger than sidebar's default so background
|
|
1398
|
+
// poll catches upcoming-week events the sidebar hasn't asked for yet.
|
|
1399
|
+
setInterval(() => {
|
|
1400
|
+
const now = Date.now();
|
|
1401
|
+
svc.getCalendarEvents(now, now + horizonDays * 86400_000)
|
|
1402
|
+
.catch((e: any) => console.error(` [calendar] poll error: ${e?.message || e}`));
|
|
1403
|
+
svc.getTasks(false)
|
|
1404
|
+
.catch((e: any) => console.error(` [tasks] poll error: ${e?.message || e}`));
|
|
1405
|
+
}, CAL_POLL_MS);
|
|
1406
|
+
|
|
1407
|
+
// Contacts poll — incremental sync via People API syncToken (persisted
|
|
1408
|
+
// per-account in the kv table). Cheap after the first run because only
|
|
1409
|
+
// changed/deleted contacts come back. 15-minute cadence picks up new
|
|
1410
|
+
// contacts added on phone / web without restart while staying well
|
|
1411
|
+
// under the People API rate limit.
|
|
1412
|
+
const CONTACTS_POLL_MS = 15 * 60_000;
|
|
1413
|
+
setInterval(() => {
|
|
1414
|
+
svc.syncGoogleContacts()
|
|
1415
|
+
.catch((e: any) => console.error(` [contacts] poll error: ${e?.message || e}`));
|
|
1416
|
+
}, CONTACTS_POLL_MS);
|
|
1417
|
+
|
|
1418
|
+
// Auto-update: periodically check npm for a newer version and push a
|
|
1419
|
+
// notification to the WebView so the user can update with one click.
|
|
1420
|
+
const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
|
|
1421
|
+
async function checkForUpdate(): Promise<void> {
|
|
1422
|
+
try {
|
|
1423
|
+
// spawn with windowsHide:true — execSync briefly flashes a cmd
|
|
1424
|
+
// window on Windows every time the periodic check fires.
|
|
1425
|
+
const { spawn } = await import("child_process");
|
|
1426
|
+
const latest = await new Promise<string>((resolve, reject) => {
|
|
1427
|
+
const child = spawn("npm", ["view", "@bobfrankston/mailx", "version"], {
|
|
1428
|
+
windowsHide: true,
|
|
1429
|
+
shell: true,
|
|
1430
|
+
});
|
|
1431
|
+
let out = "";
|
|
1432
|
+
let err = "";
|
|
1433
|
+
child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
|
|
1434
|
+
child.stderr.on("data", (d: Buffer) => { err += d.toString(); });
|
|
1435
|
+
const killer = setTimeout(() => { try { child.kill(); } catch { /* */ } reject(new Error("npm view timed out")); }, 15_000);
|
|
1436
|
+
child.on("error", (e) => { clearTimeout(killer); reject(e); });
|
|
1437
|
+
child.on("exit", (code) => {
|
|
1438
|
+
clearTimeout(killer);
|
|
1439
|
+
if (code === 0) resolve(out.trim());
|
|
1440
|
+
else reject(new Error(err.trim() || `npm view exit ${code}`));
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
const current = rootPkgVersion;
|
|
1444
|
+
if (latest && latest !== current) {
|
|
1445
|
+
console.log(` [update] New version available: ${current} → ${latest}`);
|
|
1446
|
+
handle.send({ _event: "updateAvailable", type: "updateAvailable", current, latest });
|
|
1447
|
+
}
|
|
1448
|
+
} catch (e: any) {
|
|
1449
|
+
// Silent — network down, npm not reachable, etc.
|
|
1450
|
+
console.log(` [update] Check failed: ${e.message}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
// First check after 2 minutes (don't slow down startup), then every 30 min
|
|
1454
|
+
setTimeout(() => {
|
|
1455
|
+
checkForUpdate();
|
|
1456
|
+
setInterval(checkForUpdate, UPDATE_CHECK_MS);
|
|
1457
|
+
}, 120_000);
|
|
1458
|
+
|
|
1459
|
+
// Graceful shutdown — close IMAP connections, stop timers, close DB
|
|
1460
|
+
let shuttingDown = false;
|
|
1461
|
+
async function gracefulShutdown(reason: string): Promise<void> {
|
|
1462
|
+
if (shuttingDown) return;
|
|
1463
|
+
shuttingDown = true;
|
|
1464
|
+
console.log(`${reason} — shutting down`);
|
|
1465
|
+
imapManager.stopPeriodicSync();
|
|
1466
|
+
imapManager.stopOutboxWorker();
|
|
1467
|
+
// 3s hard timeout — don't hang on broken IMAP connections
|
|
1468
|
+
const forceExit = setTimeout(() => { console.log("Forced exit"); process.exit(0); }, 3000);
|
|
1469
|
+
try { await imapManager.shutdown(); } catch { /* proceed */ }
|
|
1470
|
+
clearTimeout(forceExit);
|
|
1471
|
+
db.close();
|
|
1472
|
+
process.exit(0);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
1476
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
1477
|
+
process.on("uncaughtException", (err) => {
|
|
1478
|
+
console.error(`UNCAUGHT EXCEPTION: ${err.stack || err.message}`);
|
|
1479
|
+
gracefulShutdown("uncaughtException");
|
|
1480
|
+
});
|
|
1481
|
+
process.on("unhandledRejection", (reason: any) => {
|
|
1482
|
+
console.error(`UNHANDLED REJECTION: ${reason?.stack || reason?.message || reason}`);
|
|
1483
|
+
});
|
|
1484
|
+
process.on("exit", (code) => {
|
|
1485
|
+
console.log(`Process exit (code ${code})`);
|
|
1486
|
+
if (!shuttingDown) {
|
|
1487
|
+
imapManager.stopPeriodicSync();
|
|
1488
|
+
imapManager.stopOutboxWorker();
|
|
1489
|
+
db.close();
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
// Wait for window close, then shut down
|
|
1494
|
+
await handle.closed;
|
|
1495
|
+
await gracefulShutdown("Window closed");
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
main().catch(console.error);
|