@bobfrankston/mailx 1.0.199 → 1.0.200

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.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Post-install script: creates symlinks for workspace packages
4
+ * so they resolve as @bobfrankston/mailx-* in node_modules.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ const root = path.resolve(import.meta.dirname, "..");
9
+ const packagesDir = path.join(root, "packages");
10
+ const nmDir = path.join(root, "node_modules", "@bobfrankston");
11
+ if (!fs.existsSync(packagesDir))
12
+ process.exit(0); // not in workspace layout
13
+ fs.mkdirSync(nmDir, { recursive: true });
14
+ for (const dir of fs.readdirSync(packagesDir)) {
15
+ const pkgPath = path.join(packagesDir, dir, "package.json");
16
+ if (!fs.existsSync(pkgPath))
17
+ continue;
18
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
19
+ const name = pkg.name?.split("/")[1]; // e.g., "mailx-store" from "@bobfrankston/mailx-store"
20
+ if (!name)
21
+ continue;
22
+ const linkPath = path.join(nmDir, name);
23
+ const targetPath = path.join(packagesDir, dir);
24
+ if (fs.existsSync(linkPath))
25
+ continue; // already linked
26
+ try {
27
+ // Use junction on Windows (no admin needed), symlink on Unix
28
+ if (process.platform === "win32") {
29
+ fs.symlinkSync(targetPath, linkPath, "junction");
30
+ }
31
+ else {
32
+ fs.symlinkSync(targetPath, linkPath, "dir");
33
+ }
34
+ }
35
+ catch (e) {
36
+ console.error(`Failed to link ${name}: ${e.message}`);
37
+ }
38
+ }
39
+ //# sourceMappingURL=postinstall.js.map
package/bin/mailx.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
+ import os from "node:os";
21
22
  import net from "node:net";
22
23
  import { ports } from "@bobfrankston/miscinfo";
23
24
  import { showMessageBox, showService } from "@bobfrankston/msger";
@@ -25,14 +26,12 @@ const PORT = ports.mailx;
25
26
  const args = process.argv.slice(2);
26
27
  // Normalize: accept both -flag and --flag
27
28
  function hasFlag(name) { return args.includes(`-${name}`) || args.includes(`--${name}`); }
28
- const serverMode = hasFlag("server");
29
- const noBrowser = hasFlag("no-browser");
30
29
  const verbose = hasFlag("verbose");
31
30
  const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process
32
31
  // Auto-detach: re-spawn as background process so terminal returns immediately
33
- // Skip for: --verbose (want console), --server (needs terminal), --daemon (already detached),
32
+ // Skip for: --verbose (want console), --daemon (already detached),
34
33
  // and any command flags (setup, kill, test, etc.)
35
- if (!verbose && !serverMode && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a) && !["--no-browser"].includes(a))) {
34
+ if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a) && !["--no-browser"].includes(a))) {
36
35
  const { spawn } = await import("node:child_process");
37
36
  const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
38
37
  detached: true,
@@ -49,12 +48,12 @@ const rebuildMode = hasFlag("rebuild");
49
48
  const repairMode = hasFlag("repair");
50
49
  const importMode = hasFlag("import");
51
50
  // Validate arguments
52
- const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "native-imap", "log", "import", "email", "mail", "daemon"];
51
+ const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon"];
53
52
  for (const arg of args) {
54
53
  const flag = arg.replace(/^--?/, "");
55
54
  if (arg.startsWith("-") && !knownFlags.includes(flag)) {
56
55
  console.error(`Unknown option: ${arg}`);
57
- console.error("Usage: mailx [-server] [-verbose] [-kill] [-rebuild] [-v] [-setup] [-no-browser] [-external]");
56
+ console.error("Usage: mailx [-verbose] [-kill] [-rebuild] [-v] [-setup]");
58
57
  process.exit(1);
59
58
  }
60
59
  }
@@ -578,10 +577,62 @@ async function runTest() {
578
577
  console.log("Test complete. Check your inbox for the test message(s).");
579
578
  process.exit(0);
580
579
  }
580
+ /** Register this client on GDrive — writes/updates clients.jsonc with device info */
581
+ async function registerClient(settings) {
582
+ const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
583
+ // Device ID: stable per machine, stored locally
584
+ const deviceIdPath = path.join(os.homedir(), ".mailx", "device-id");
585
+ let deviceId;
586
+ if (fs.existsSync(deviceIdPath)) {
587
+ deviceId = fs.readFileSync(deviceIdPath, "utf-8").trim();
588
+ }
589
+ else {
590
+ deviceId = `${os.hostname()}-${Date.now().toString(36)}`;
591
+ fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true });
592
+ fs.writeFileSync(deviceIdPath, deviceId);
593
+ }
594
+ // Get local IP
595
+ let localIp = "";
596
+ try {
597
+ const nets = os.networkInterfaces();
598
+ for (const addrs of Object.values(nets)) {
599
+ for (const addr of addrs || []) {
600
+ if (addr.family === "IPv4" && !addr.internal) {
601
+ localIp = addr.address;
602
+ break;
603
+ }
604
+ }
605
+ if (localIp)
606
+ break;
607
+ }
608
+ }
609
+ catch { /* ignore */ }
610
+ // Read existing clients.jsonc from cloud
611
+ let clients = {};
612
+ try {
613
+ const content = await cloudRead("clients.jsonc");
614
+ if (content)
615
+ clients = JSON.parse(content);
616
+ }
617
+ catch { /* start fresh */ }
618
+ // Update this device's entry
619
+ clients[deviceId] = {
620
+ hostname: os.hostname(),
621
+ platform: `${process.platform} ${process.arch}`,
622
+ accounts: settings.accounts.map((a) => a.id),
623
+ lastSeen: new Date().toISOString(),
624
+ ip: localIp,
625
+ version: JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version,
626
+ };
627
+ // Write back
628
+ const ok = await cloudWrite("clients.jsonc", JSON.stringify(clients, null, 2));
629
+ if (ok)
630
+ console.log(` [client] Registered device: ${deviceId}`);
631
+ }
581
632
  async function main() {
582
633
  log(`Platform: ${process.platform} ${process.arch}`);
583
634
  log(`Node: ${process.version}`);
584
- log(`Mode: ${serverMode ? "server" : setupMode ? "setup" : "auto"}`);
635
+ log(`Mode: ${setupMode ? "setup" : "auto"}`);
585
636
  // Test connectivity
586
637
  if (testMode) {
587
638
  await runTest();
@@ -632,18 +683,7 @@ async function main() {
632
683
  console.log = (...a) => { logStream.write(`${ts()} ${a.join(" ")}\n`); };
633
684
  console.error = (...a) => { logStream.write(`${ts()} ERROR ${a.join(" ")}\n`); };
634
685
  }
635
- // --server mode: Express + HTTP (for dev/remote access)
636
- if (serverMode) {
637
- console.log("Starting mailx HTTP server...");
638
- if (hasFlag("external"))
639
- process.argv.push("--external");
640
- await import("../packages/mailx-server/index.js");
641
- if (!noBrowser)
642
- launchMsger(`http://127.0.0.1:${PORT}`);
643
- await new Promise(() => { }); // keep alive
644
- return;
645
- }
646
- // Default: service mode — no TCP, IPC via msger
686
+ // IPC service mode no HTTP server
647
687
  console.log("Starting mailx service...");
648
688
  const { MailxDB } = await import("@bobfrankston/mailx-store");
649
689
  const { ImapManager } = await import("@bobfrankston/mailx-imap");
@@ -667,7 +707,9 @@ async function main() {
667
707
  const clientDir = path.join(import.meta.dirname, "..", "client");
668
708
  const mailxapiPath = path.join(clientDir, "lib", "mailxapi.js");
669
709
  const mailxapiScript = fs.readFileSync(mailxapiPath, "utf-8");
710
+ const rootPkgVersion = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version;
670
711
  const handle = showService({
712
+ title: `mailx v${rootPkgVersion}`,
671
713
  url: "index.html",
672
714
  contentDir: clientDir,
673
715
  initScript: mailxapiScript,
@@ -680,6 +722,11 @@ async function main() {
680
722
  const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
681
723
  handle.onRequest(async (req) => {
682
724
  if (!req._action) {
725
+ // msger sends {"button":"OK","closed":true} on navigation — ignore it, don't exit
726
+ if (req.closed || req.button) {
727
+ console.log(`[ipc] ← ignored close signal during navigation: ${JSON.stringify(req).substring(0, 100)}`);
728
+ return;
729
+ }
683
730
  console.log(`[ipc] ← ignored (no _action): ${JSON.stringify(req).substring(0, 100)}`);
684
731
  return;
685
732
  }
@@ -743,6 +790,8 @@ async function main() {
743
790
  console.error(` Failed: ${account.id}: ${e.message}`);
744
791
  }
745
792
  }
793
+ // Register this client device on GDrive (fire-and-forget)
794
+ registerClient(settings).catch(() => { });
746
795
  // Start sync in background — don't block
747
796
  if (settings.accounts.some(a => a.enabled)) {
748
797
  imapManager.syncAll().catch(e => console.error(` Sync error: ${e.message}`));
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Cloud storage for mailx settings — Google Drive API (drive.file scope).
3
+ *
4
+ * Uses a single app-owned "mailx" folder on Drive, accessed by folder ID.
5
+ * The drive.file scope only sees files/folders created by this OAuth client,
6
+ * which prevents conflicts with existing folders of the same name.
7
+ * All machines using the same OAuth client ID share the same folder.
8
+ *
9
+ * ── Restoring removed providers ──
10
+ * OneDrive (removed 2026-04-06): Required microsoft-credentials.json in ~/.mailx/,
11
+ * MS_SCOPES = "https://graph.microsoft.com/Files.ReadWrite offline_access",
12
+ * authenticateOAuth with tokenDir ~/.mailx/tokens/microsoft/.
13
+ * Read/write via Graph API: GET/PUT https://graph.microsoft.com/v1.0/me/drive/root:/{path}:/content
14
+ * Azure AD app registration needed: https://portal.azure.com → App registrations → Desktop app
15
+ *
16
+ * Dropbox (removed 2026-04-06): Never implemented — placeholder only.
17
+ * Would need Dropbox OAuth app, token management, and Dropbox API v2 calls.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { authenticateOAuth } from "@bobfrankston/oauthsupport";
22
+ const SETTINGS_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
23
+ // ── Credentials ──
24
+ // Google Drive: reuse iflow's OAuth credentials (same Google Cloud project)
25
+ function findGoogleCredentials() {
26
+ // Check mailx local dir first, then iflow package
27
+ const local = path.join(SETTINGS_DIR, "google-credentials.json");
28
+ if (fs.existsSync(local))
29
+ return local;
30
+ try {
31
+ let dir = import.meta.dirname;
32
+ for (let i = 0; i < 5; i++) {
33
+ for (const pkg of ["iflow-direct", "iflow"]) {
34
+ for (const name of ["iflow-credentials.json"]) {
35
+ const p = path.join(dir, "node_modules", "@bobfrankston", pkg, name);
36
+ if (fs.existsSync(p))
37
+ return p;
38
+ }
39
+ }
40
+ const parent = path.dirname(dir);
41
+ if (parent === dir)
42
+ break;
43
+ dir = parent;
44
+ }
45
+ }
46
+ catch { /* iflow not installed */ }
47
+ return null;
48
+ }
49
+ const GDRIVE_TOKEN_DIR = path.join(SETTINGS_DIR, "tokens", "gdrive");
50
+ // drive.file: app can only see files it created. Safe, publishable without security audit.
51
+ // All machines sharing the same OAuth client ID see the same files.
52
+ const GDRIVE_SCOPES = "https://www.googleapis.com/auth/drive.file";
53
+ const GDRIVE_FOLDER_NAME = "mailx";
54
+ // ── Token helpers ──
55
+ async function getGoogleDriveToken() {
56
+ const creds = findGoogleCredentials();
57
+ if (!creds) {
58
+ console.error(" [cloud] No Google credentials found (checked ~/.mailx/google-credentials.json and iflow package)");
59
+ return null;
60
+ }
61
+ // Delete stale token if scope changed (drive → drive.file or vice versa)
62
+ const tokenPath = path.join(GDRIVE_TOKEN_DIR, "token.json");
63
+ if (fs.existsSync(tokenPath)) {
64
+ try {
65
+ const existing = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
66
+ if (existing.scope && existing.scope !== GDRIVE_SCOPES) {
67
+ console.log(` [cloud] Scope changed — re-authenticating...`);
68
+ fs.unlinkSync(tokenPath);
69
+ }
70
+ }
71
+ catch { /* ignore parse errors */ }
72
+ }
73
+ try {
74
+ const token = await authenticateOAuth(creds, {
75
+ scope: GDRIVE_SCOPES,
76
+ tokenDirectory: GDRIVE_TOKEN_DIR,
77
+ tokenFileName: "token.json",
78
+ credentialsKey: "installed",
79
+ includeOfflineAccess: true,
80
+ });
81
+ return token?.access_token || null;
82
+ }
83
+ catch (e) {
84
+ console.error(` [cloud] Google Drive auth failed: ${e.message}`);
85
+ return null;
86
+ }
87
+ }
88
+ // ── Google Drive API (folder-ID based) ──
89
+ /** Find the app-owned "mailx" folder, or create it. Returns folder ID. */
90
+ export async function gDriveFindOrCreateFolder() {
91
+ const token = await getGoogleDriveToken();
92
+ if (!token)
93
+ return null;
94
+ try {
95
+ // Search for existing folder (created by this OAuth client)
96
+ const query = `name='${GDRIVE_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
97
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
98
+ headers: { Authorization: `Bearer ${token}` },
99
+ });
100
+ if (!res.ok) {
101
+ console.error(` [cloud] gdrive folder search: ${res.status} ${res.statusText}`);
102
+ return null;
103
+ }
104
+ const data = await res.json();
105
+ if (data.files?.[0]) {
106
+ console.log(` [cloud] Found existing '${GDRIVE_FOLDER_NAME}' folder: ${data.files[0].id}`);
107
+ return data.files[0].id;
108
+ }
109
+ // Create folder
110
+ const createRes = await fetch("https://www.googleapis.com/drive/v3/files", {
111
+ method: "POST",
112
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
113
+ body: JSON.stringify({ name: GDRIVE_FOLDER_NAME, mimeType: "application/vnd.google-apps.folder" }),
114
+ });
115
+ if (!createRes.ok) {
116
+ console.error(` [cloud] gdrive folder create: ${createRes.status} ${createRes.statusText}`);
117
+ return null;
118
+ }
119
+ const created = await createRes.json();
120
+ console.log(` [cloud] Created '${GDRIVE_FOLDER_NAME}' folder: ${created.id}`);
121
+ return created.id;
122
+ }
123
+ catch (e) {
124
+ console.error(` [cloud] gdrive folder setup: ${e.message}`);
125
+ return null;
126
+ }
127
+ }
128
+ /** Read a file by name from a folder (by ID) */
129
+ async function gDriveReadFromFolder(folderId, fileName) {
130
+ const token = await getGoogleDriveToken();
131
+ if (!token) {
132
+ console.error(` [cloud] gdrive read ${fileName}: no token`);
133
+ return null;
134
+ }
135
+ try {
136
+ // Find file in folder
137
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
138
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
139
+ headers: { Authorization: `Bearer ${token}` },
140
+ });
141
+ if (!res.ok) {
142
+ console.error(` [cloud] gdrive lookup '${fileName}': ${res.status}`);
143
+ return null;
144
+ }
145
+ const data = await res.json();
146
+ const fileId = data.files?.[0]?.id;
147
+ if (!fileId)
148
+ return null;
149
+ // Download content
150
+ const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
151
+ headers: { Authorization: `Bearer ${token}` },
152
+ });
153
+ if (!contentRes.ok) {
154
+ console.error(` [cloud] gdrive download ${fileName}: ${contentRes.status}`);
155
+ return null;
156
+ }
157
+ return await contentRes.text();
158
+ }
159
+ catch (e) {
160
+ console.error(` [cloud] gdrive read ${fileName}: ${e.message}`);
161
+ return null;
162
+ }
163
+ }
164
+ /** Write a file by name to a folder (by ID) — creates or updates */
165
+ async function gDriveWriteToFolder(folderId, fileName, content) {
166
+ const token = await getGoogleDriveToken();
167
+ if (!token)
168
+ return false;
169
+ try {
170
+ // Check if file exists in folder
171
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
172
+ const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
173
+ headers: { Authorization: `Bearer ${token}` },
174
+ });
175
+ const findData = await findRes.json();
176
+ const existingId = findData.files?.[0]?.id;
177
+ if (existingId) {
178
+ // Update existing file
179
+ const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
180
+ method: "PATCH",
181
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
182
+ body: content,
183
+ });
184
+ return res.ok;
185
+ }
186
+ else {
187
+ // Create new file in folder
188
+ const boundary = "mailx_boundary_" + Date.now();
189
+ const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
190
+ const body = `--${boundary}\r\nContent-Type: application/json\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${content}\r\n--${boundary}--`;
191
+ const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
192
+ method: "POST",
193
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
194
+ body,
195
+ });
196
+ return res.ok;
197
+ }
198
+ }
199
+ catch (e) {
200
+ console.error(` [cloud] gdrive write ${fileName}: ${e.message}`);
201
+ return false;
202
+ }
203
+ }
204
+ /**
205
+ * Get a cloud file provider. For gdrive, pass the folder ID.
206
+ * Files are stored flat in the folder (no subdirectory navigation).
207
+ */
208
+ export function getCloudProvider(provider, folderId) {
209
+ switch (provider) {
210
+ case "google":
211
+ case "gdrive":
212
+ if (!folderId) {
213
+ console.error(" [cloud] gdrive requires a folder ID — run initCloudConfig first");
214
+ return null;
215
+ }
216
+ return {
217
+ read: (fileName) => gDriveReadFromFolder(folderId, fileName),
218
+ write: (fileName, content) => gDriveWriteToFolder(folderId, fileName, content),
219
+ exists: async (fileName) => (await gDriveReadFromFolder(folderId, fileName)) !== null,
220
+ };
221
+ case "local":
222
+ return {
223
+ read: async (p) => { try {
224
+ return fs.readFileSync(p, "utf-8");
225
+ }
226
+ catch {
227
+ return null;
228
+ } },
229
+ write: async (p, c) => { try {
230
+ fs.writeFileSync(p, c);
231
+ return true;
232
+ }
233
+ catch {
234
+ return false;
235
+ } },
236
+ exists: async (p) => fs.existsSync(p),
237
+ };
238
+ default:
239
+ console.error(` [cloud] Provider "${provider}" not supported — only gdrive is available`);
240
+ return null;
241
+ }
242
+ }
243
+ //# sourceMappingURL=cloud.js.map