@bobfrankston/mailx 1.0.138 → 1.0.139

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 CHANGED
@@ -3,17 +3,22 @@
3
3
  * mailx -- email client
4
4
  *
5
5
  * Usage:
6
- * mailx Launch native app or fallback to browser
7
- * mailx --server Start Express server + open browser
8
- * mailx --no-browser Server mode, don't open browser
9
- * mailx --external Bind to all interfaces (server mode only)
6
+ * mailx Start server + open in msger (or browser fallback)
7
+ * mailx --server Start server + open browser (skip msger)
8
+ * mailx --no-browser Start server only (headless)
9
+ * mailx --external Bind to all interfaces
10
10
  * mailx --verbose Show detailed startup info
11
11
  * mailx -v / --version Show version and exit
12
+ * mailx -kill Kill running mailx processes
13
+ * mailx -setup Interactive first-time setup
14
+ * mailx -test Test account connectivity
15
+ * mailx -rebuild Wipe local cache, keep accounts
12
16
  */
13
17
  import fs from "node:fs";
14
18
  import path from "node:path";
15
19
  import net from "node:net";
16
- const PORT = 9333;
20
+ import { ports } from "@bobfrankston/miscinfo";
21
+ const PORT = ports.mailx;
17
22
  const args = process.argv.slice(2);
18
23
  // Normalize: accept both -flag and --flag
19
24
  function hasFlag(name) { return args.includes(`-${name}`) || args.includes(`--${name}`); }
@@ -76,13 +81,6 @@ if (hasFlag("kill")) {
76
81
  }
77
82
  }
78
83
  catch { /* */ }
79
- // Kill mailx-app.exe
80
- try {
81
- execSync("taskkill /F /IM mailx-app.exe", { stdio: "pipe" });
82
- console.log("Killed mailx-app.exe");
83
- killed++;
84
- }
85
- catch { /* */ }
86
84
  }
87
85
  else {
88
86
  try {
@@ -102,13 +100,6 @@ if (hasFlag("kill")) {
102
100
  }
103
101
  catch { /* */ }
104
102
  }
105
- // Remove lock file
106
- const lockPath = path.join(mailxDir, "mailx-app.lock");
107
- try {
108
- fs.unlinkSync(lockPath);
109
- log("Removed lock file");
110
- }
111
- catch { /* */ }
112
103
  if (killed === 0)
113
104
  console.log("No mailx processes found");
114
105
  process.exit(0);
@@ -188,49 +179,31 @@ function openBrowser(url) {
188
179
  exec(`xdg-open "${url}"`);
189
180
  });
190
181
  }
182
+ /** Launch msger pointing at the server URL; fall back to browser if msger not found */
183
+ function launchMsger(url) {
184
+ import("node:child_process").then(({ spawn }) => {
185
+ const child = spawn("msger", ["-url", url], { detached: true, stdio: "ignore" });
186
+ child.on("error", () => {
187
+ log("msger not found — opening in browser");
188
+ openBrowser(url);
189
+ });
190
+ child.unref();
191
+ });
192
+ }
191
193
  async function prompt(question) {
192
194
  const readline = await import("readline");
193
195
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
194
196
  return new Promise(resolve => rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); }));
195
197
  }
196
- /** Detect mounted cloud drives that might have settings */
197
- function findCloudSettings() {
198
- const home = process.env.USERPROFILE || process.env.HOME || "";
199
- const checks = [
200
- // OneDrive
201
- { provider: "onedrive", dir: process.env.OneDrive ? path.join(process.env.OneDrive, "home", ".mailx") : "" },
202
- { provider: "onedrive", dir: path.join(home, "OneDrive", "home", ".mailx") },
203
- { provider: "onedrive", dir: path.join(home, "onedrive", "home", ".mailx") },
204
- // Google Drive
205
- { provider: "gdrive", dir: path.join(home, "Google Drive", "My Drive", "home", ".mailx") },
206
- { provider: "gdrive", dir: path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx") },
207
- // Dropbox
208
- { provider: "dropbox", dir: path.join(home, "Dropbox", "home", ".mailx") },
209
- ];
210
- // Drive letters on Windows
211
- if (process.platform === "win32") {
212
- for (const letter of ["G", "H", "I", "J", "K"]) {
213
- checks.push({ provider: "gdrive", dir: path.join(`${letter}:`, "My Drive", "home", ".mailx") });
214
- }
215
- }
216
- for (const c of checks) {
217
- if (c.dir && fs.existsSync(c.dir) && (fs.existsSync(path.join(c.dir, "settings.jsonc")) || fs.existsSync(path.join(c.dir, "accounts.jsonc")))) {
218
- return c;
219
- }
220
- }
221
- return null;
222
- }
223
- /** Check if mailx is configured (has accounts) */
198
+ /** Check if mailx is configured (has local config or accounts) */
224
199
  function hasConfig() {
225
200
  const home = process.env.USERPROFILE || process.env.HOME || "";
226
201
  const mailxDir = path.join(home, ".mailx");
227
- // Check local settings
228
- for (const f of ["settings.jsonc", "accounts.jsonc", "config.jsonc"]) {
202
+ for (const f of ["config.jsonc", "accounts.jsonc", "settings.jsonc"]) {
229
203
  if (fs.existsSync(path.join(mailxDir, f)))
230
204
  return true;
231
205
  }
232
- // Check cloud drives
233
- return findCloudSettings() !== null;
206
+ return false;
234
207
  }
235
208
  /** Try to auto-discover mail server settings from email domain */
236
209
  async function autoDiscover(domain) {
@@ -265,6 +238,29 @@ async function autoDiscover(domain) {
265
238
  }
266
239
  }
267
240
  catch { /* DNS failed */ }
241
+ // 3. Try MX-based detection (Google Workspace, Microsoft 365)
242
+ try {
243
+ const dns = await import("node:dns/promises");
244
+ const records = await dns.resolveMx(domain);
245
+ for (const mx of records) {
246
+ const host = mx.exchange.toLowerCase();
247
+ if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
248
+ return {
249
+ imap: { host: "imap.gmail.com", port: 993, auth: "oauth2" },
250
+ smtp: { host: "smtp.gmail.com", port: 587, auth: "oauth2" },
251
+ source: `MX → Google (${host})`,
252
+ };
253
+ }
254
+ if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
255
+ return {
256
+ imap: { host: "outlook.office365.com", port: 993, auth: "oauth2" },
257
+ smtp: { host: "smtp.office365.com", port: 587, auth: "oauth2" },
258
+ source: `MX → Microsoft (${host})`,
259
+ };
260
+ }
261
+ }
262
+ }
263
+ catch { /* DNS failed */ }
268
264
  return null;
269
265
  }
270
266
  /** Prompt user for account details, auto-discover where possible */
@@ -307,44 +303,15 @@ async function promptForAccount(intro) {
307
303
  smtp: { host: smtpHost },
308
304
  };
309
305
  }
310
- /** Find a mounted cloud drive (for saving new settings) */
311
- function findMountedDrive() {
312
- const home = process.env.USERPROFILE || process.env.HOME || "";
313
- const checks = [
314
- { provider: "onedrive", base: process.env.OneDrive || "", dir: process.env.OneDrive ? path.join(process.env.OneDrive, "home", ".mailx") : "" },
315
- { provider: "onedrive", base: path.join(home, "OneDrive"), dir: path.join(home, "OneDrive", "home", ".mailx") },
316
- { provider: "onedrive", base: path.join(home, "onedrive"), dir: path.join(home, "onedrive", "home", ".mailx") },
317
- { provider: "gdrive", base: path.join(home, "Google Drive", "My Drive"), dir: path.join(home, "Google Drive", "My Drive", "home", ".mailx") },
318
- { provider: "gdrive", base: path.join(home, "Google Drive Streaming", "My Drive"), dir: path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx") },
319
- { provider: "dropbox", base: path.join(home, "Dropbox"), dir: path.join(home, "Dropbox", "home", ".mailx") },
320
- ];
321
- for (const c of checks) {
322
- if (c.base && fs.existsSync(c.base))
323
- return c;
324
- }
325
- return null;
326
- }
327
- /** Interactive first-time setup */
306
+ /** Interactive first-time setup GDrive API for cloud storage */
328
307
  async function runSetup() {
329
308
  console.log("\nmailx — first-time setup\n");
330
309
  const home = process.env.USERPROFILE || process.env.HOME || "";
331
310
  const mailxDir = path.join(home, ".mailx");
332
- // Check for existing cloud settings
333
- const cloud = findCloudSettings();
334
- if (cloud) {
335
- console.log(`Found existing settings on ${cloud.provider}: ${cloud.dir}`);
336
- fs.mkdirSync(mailxDir, { recursive: true });
337
- const config = { sharedDir: { provider: cloud.provider, path: "home/.mailx" } };
338
- fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
339
- console.log(`Created ~/.mailx/config.jsonc pointing to ${cloud.provider}`);
340
- console.log("Setup complete. Starting mailx...\n");
341
- return true;
342
- }
343
- // No cloud settings found — prompt for email
344
- console.log("No existing settings found on OneDrive, Google Drive, or Dropbox.\n");
345
- const account = await promptForAccount("Gmail is recommended as the first account (provides contacts + cloud sync).\n");
311
+ console.log("Gmail is recommended as the first account (provides contacts, calendar, and cloud settings sync).\n");
312
+ const account = await promptForAccount();
346
313
  if (!account) {
347
- console.log(`\nCreate settings manually at: ${path.join(mailxDir, "settings.jsonc")}`);
314
+ console.log(`\nNo account added. Open http://127.0.0.1:${PORT} in a browser to set up via the UI.`);
348
315
  fs.mkdirSync(mailxDir, { recursive: true });
349
316
  return false;
350
317
  }
@@ -356,43 +323,47 @@ async function runSetup() {
356
323
  sync: { intervalMinutes: 5, historyDays: 0 },
357
324
  };
358
325
  const domain = account.email.split("@")[1]?.toLowerCase() || "";
359
- const isGmail = domain === "gmail.com" || domain === "googlemail.com";
360
- // Ask where to store settings
361
- const mountedDrive = findMountedDrive();
362
- let storageChoice = "local";
363
- if (mountedDrive) {
364
- const useCloud = await prompt(`Store settings on ${mountedDrive.provider} (syncs across machines)? [Y/n]: `);
365
- if (!useCloud || useCloud.toLowerCase() !== "n")
366
- storageChoice = mountedDrive.provider;
367
- }
368
- else if (isGmail) {
369
- const useGDrive = await prompt("Store settings on Google Drive (syncs across machines, editable via drive.google.com)? [Y/n]: ");
370
- if (!useGDrive || useGDrive.toLowerCase() !== "n")
371
- storageChoice = "gdrive-api";
326
+ // Detect if this is a Google-hosted domain (for GDrive cloud storage)
327
+ let isGoogle = ["gmail.com", "googlemail.com"].includes(domain);
328
+ if (!isGoogle) {
329
+ try {
330
+ const dns = await import("node:dns/promises");
331
+ const records = await dns.resolveMx(domain);
332
+ isGoogle = records.some(mx => {
333
+ const host = mx.exchange.toLowerCase();
334
+ return host.endsWith(".google.com") || host.endsWith(".googlemail.com");
335
+ });
336
+ if (isGoogle)
337
+ console.log(` ${domain} is hosted on Google (detected via MX)`);
338
+ }
339
+ catch { /* DNS lookup failed */ }
372
340
  }
373
341
  fs.mkdirSync(mailxDir, { recursive: true });
374
- if (storageChoice === "gdrive-api") {
375
- // Save to Google Drive via API (not mounted)
342
+ if (isGoogle) {
343
+ // Save to Google Drive via API (folder-ID based, no path navigation)
376
344
  console.log("\nSaving settings to Google Drive via API...");
377
345
  try {
378
- const { getCloudProvider } = await import("../packages/mailx-settings/cloud.js");
379
- const gdrive = getCloudProvider("gdrive");
380
- if (gdrive) {
381
- const content = JSON.stringify(settings, null, 2);
382
- const ok = await gdrive.write("home/.mailx/settings.jsonc", content);
383
- if (ok) {
384
- console.log("Settings saved to Google Drive: home/.mailx/settings.jsonc");
385
- const config = { sharedDir: { provider: "gdrive", path: "home/.mailx" } };
386
- fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
387
- console.log("Local config created pointing to Google Drive.");
388
- }
389
- else {
390
- console.log("Google Drive write failed — saving locally instead.");
391
- fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
346
+ const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
347
+ const folderId = await gDriveFindOrCreateFolder();
348
+ if (folderId) {
349
+ const gdrive = getCloudProvider("gdrive", folderId);
350
+ if (gdrive) {
351
+ const content = JSON.stringify(settings, null, 2);
352
+ const ok = await gdrive.write("settings.jsonc", content);
353
+ if (ok) {
354
+ console.log("Settings saved to Google Drive (mailx folder)");
355
+ const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
356
+ fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
357
+ console.log("Local config created with Drive folder ID.");
358
+ }
359
+ else {
360
+ console.log("Google Drive write failed — saving locally instead.");
361
+ fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
362
+ }
392
363
  }
393
364
  }
394
365
  else {
395
- console.log("Google Drive API not available — saving locally.");
366
+ console.log("Google Drive folder setup failed — saving locally.");
396
367
  fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
397
368
  }
398
369
  }
@@ -401,17 +372,8 @@ async function runSetup() {
401
372
  fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
402
373
  }
403
374
  }
404
- else if (storageChoice !== "local" && mountedDrive) {
405
- // Save to mounted cloud drive
406
- console.log(`\nSaving settings to ${mountedDrive.provider} at ${mountedDrive.dir}...`);
407
- fs.mkdirSync(mountedDrive.dir, { recursive: true });
408
- fs.writeFileSync(path.join(mountedDrive.dir, "settings.jsonc"), JSON.stringify(settings, null, 2));
409
- const config = { sharedDir: { provider: mountedDrive.provider, path: "home/.mailx" } };
410
- fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
411
- console.log("Settings saved to cloud drive + local config created.");
412
- }
413
375
  else {
414
- // Save locally
376
+ // Non-Google account — save locally
415
377
  console.log(`\nSaving settings to ${mailxDir}...`);
416
378
  fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
417
379
  }
@@ -507,7 +469,7 @@ async function runTest() {
507
469
  async function main() {
508
470
  log(`Platform: ${process.platform} ${process.arch}`);
509
471
  log(`Node: ${process.version}`);
510
- log(`Mode: ${serverMode ? "server" : setupMode ? "setup" : "auto-detect"}`);
472
+ log(`Mode: ${serverMode ? "server" : setupMode ? "setup" : "auto"}`);
511
473
  // Test connectivity
512
474
  if (testMode) {
513
475
  await runTest();
@@ -537,87 +499,49 @@ async function main() {
537
499
  process.exit(0);
538
500
  }
539
501
  // Auto-detect first run — enter setup if no config exists
540
- if (setupMode || (!serverMode && !hasConfig())) {
502
+ if (setupMode || !hasConfig()) {
541
503
  if (!setupMode)
542
504
  console.log("No mailx configuration found.");
543
505
  await runSetup();
544
506
  }
545
- if (serverMode) {
546
- // Server mode Express + WebSocket, open browser
547
- const inUse = await isPortInUse(PORT);
548
- if (inUse) {
549
- console.log(`mailx server already running on port ${PORT}`);
550
- if (!noBrowser)
551
- openBrowser(`http://127.0.0.1:${PORT}`);
552
- return;
553
- }
554
- console.log("Starting mailx server...");
555
- log(`Loading server from: ${path.join(import.meta.dirname, "..", "packages", "mailx-server", "index.js")}`);
556
- if (hasFlag("external"))
557
- process.argv.push("--external");
558
- await import("../packages/mailx-server/index.js");
507
+ // Check if server is already running
508
+ const inUse = await isPortInUse(PORT);
509
+ if (inUse) {
510
+ console.log(`mailx server already running on port ${PORT}`);
511
+ const url = `http://127.0.0.1:${PORT}`;
559
512
  if (!noBrowser) {
560
- for (let i = 0; i < 30; i++) {
561
- await new Promise(r => setTimeout(r, 200));
562
- if (await isPortInUse(PORT))
563
- break;
564
- }
565
- openBrowser(`http://127.0.0.1:${PORT}`);
566
- console.log("mailx opened (browser)");
513
+ if (serverMode)
514
+ openBrowser(url);
515
+ else
516
+ launchMsger(url);
567
517
  }
568
- // Keep process alive — server is running
569
- await new Promise(() => { });
518
+ return;
570
519
  }
571
- else {
572
- // Default: launch native WebView app
573
- let binaryName;
574
- if (process.platform === "win32")
575
- binaryName = "mailx-app.exe";
576
- else if (process.platform === "darwin")
577
- binaryName = process.arch === "arm64" ? "mailx-app-arm64" : "mailx-app";
578
- else
579
- binaryName = process.arch === "arm64" ? "mailx-app-linux-aarch64" : "mailx-app-linux";
580
- const launcherPaths = [
581
- path.join(import.meta.dirname, "..", "launcher", "bin", binaryName),
582
- path.join(import.meta.dirname, "..", "launcher", "target", "release", binaryName),
583
- ];
584
- log(`Looking for native launcher: ${binaryName}`);
585
- for (const p of launcherPaths)
586
- log(` ${fs.existsSync(p) ? "FOUND" : "not found"}: ${p}`);
587
- let launcherPath = launcherPaths.find(p => fs.existsSync(p));
588
- // On Linux, skip native launcher if no display server available
589
- if (launcherPath && process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
590
- log("No display server (DISPLAY/WAYLAND_DISPLAY not set) — skipping native launcher");
591
- launcherPath = undefined;
520
+ // Start Express server in-process
521
+ console.log("Starting mailx server...");
522
+ log(`Loading server from: ${path.join(import.meta.dirname, "..", "packages", "mailx-server", "index.js")}`);
523
+ if (hasFlag("external"))
524
+ process.argv.push("--external");
525
+ await import("../packages/mailx-server/index.js");
526
+ // Open UI once server is ready
527
+ if (!noBrowser) {
528
+ const url = `http://127.0.0.1:${PORT}`;
529
+ for (let i = 0; i < 30; i++) {
530
+ await new Promise(r => setTimeout(r, 200));
531
+ if (await isPortInUse(PORT))
532
+ break;
592
533
  }
593
- if (launcherPath) {
594
- console.log("Starting mailx...");
595
- log(`Launching: ${launcherPath}`);
596
- const { spawn } = await import("node:child_process");
597
- try {
598
- const child = spawn(launcherPath, args, { detached: true, stdio: "ignore" });
599
- child.on("error", () => {
600
- console.log("Native launcher failed, starting in browser mode...");
601
- process.argv.push("--server");
602
- main();
603
- });
604
- child.unref();
605
- console.log("mailx launched");
606
- }
607
- catch (e) {
608
- console.log(`Native launcher failed: ${e.message}`);
609
- console.log("Starting in browser mode...");
610
- process.argv.push("--server");
611
- await main();
612
- }
534
+ if (serverMode) {
535
+ openBrowser(url);
536
+ console.log("mailx opened (browser)");
613
537
  }
614
538
  else {
615
- console.log("Starting in browser mode...");
616
- log("No native launcher — falling back to --server mode");
617
- process.argv.push("--server");
618
- await main(); // recurse with --server
539
+ launchMsger(url);
540
+ console.log("mailx opened");
619
541
  }
620
542
  }
543
+ // Keep process alive — server is running
544
+ await new Promise(() => { });
621
545
  }
622
546
  main().catch(console.error);
623
547
  //# sourceMappingURL=mailx.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.138",
3
+ "version": "1.0.139",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -148,7 +148,7 @@ export function createApiRouter(db, imapManager) {
148
148
  const domain = email.split("@")[1]?.toLowerCase() || "";
149
149
  const detected = await detectEmailProvider(domain);
150
150
  if (detected?.cloud) {
151
- initCloudConfig(detected.cloud);
151
+ await initCloudConfig(detected.cloud);
152
152
  }
153
153
  // Build account config
154
154
  const account = { email, name: name || email.split("@")[0] };
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Cloud storage for mailx settings — Google Drive API.
3
- * Reads/writes settings files on Google Drive when no local mount is available.
4
- * Falls back to local cache when offline.
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.
5
8
  *
6
9
  * ── Restoring removed providers ──
7
10
  * OneDrive (removed 2026-04-06): Required microsoft-credentials.json in ~/.mailx/,
@@ -13,11 +16,20 @@
13
16
  * Dropbox (removed 2026-04-06): Never implemented — placeholder only.
14
17
  * Would need Dropbox OAuth app, token management, and Dropbox API v2 calls.
15
18
  */
19
+ /** Find the app-owned "mailx" folder, or create it. Returns folder ID. */
20
+ export declare function gDriveFindOrCreateFolder(): Promise<string | null>;
16
21
  export type CloudProvider = "gdrive" | "google" | "local";
17
22
  export interface CloudFile {
23
+ /** Read a file. For gdrive, path is just the filename (folder ID is implicit). */
18
24
  read(filePath: string): Promise<string | null>;
25
+ /** Write a file. For gdrive, path is just the filename. */
19
26
  write(filePath: string, content: string): Promise<boolean>;
27
+ /** Check if a file exists. */
20
28
  exists(filePath: string): Promise<boolean>;
21
29
  }
22
- export declare function getCloudProvider(provider: string): CloudFile | null;
30
+ /**
31
+ * Get a cloud file provider. For gdrive, pass the folder ID.
32
+ * Files are stored flat in the folder (no subdirectory navigation).
33
+ */
34
+ export declare function getCloudProvider(provider: string, folderId?: string): CloudFile | null;
23
35
  //# sourceMappingURL=cloud.d.ts.map
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Cloud storage for mailx settings — Google Drive API.
3
- * Reads/writes settings files on Google Drive when no local mount is available.
4
- * Falls back to local cache when offline.
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.
5
8
  *
6
9
  * ── Restoring removed providers ──
7
10
  * OneDrive (removed 2026-04-06): Required microsoft-credentials.json in ~/.mailx/,
@@ -24,9 +27,7 @@ function findGoogleCredentials() {
24
27
  const local = path.join(SETTINGS_DIR, "google-credentials.json");
25
28
  if (fs.existsSync(local))
26
29
  return local;
27
- // Try to find iflow's credentials via import.meta.resolve or node_modules walk
28
30
  try {
29
- // Walk up from this package to find iflow in node_modules
30
31
  let dir = import.meta.dirname;
31
32
  for (let i = 0; i < 5; i++) {
32
33
  for (const name of ["iflow-credentials.json", "credentials.json"]) {
@@ -44,7 +45,10 @@ function findGoogleCredentials() {
44
45
  return null;
45
46
  }
46
47
  const GDRIVE_TOKEN_DIR = path.join(SETTINGS_DIR, "tokens", "gdrive");
48
+ // drive.file: app can only see files it created. Safe, publishable without security audit.
49
+ // All machines sharing the same OAuth client ID see the same files.
47
50
  const GDRIVE_SCOPES = "https://www.googleapis.com/auth/drive.file";
51
+ const GDRIVE_FOLDER_NAME = "mailx";
48
52
  // ── Token helpers ──
49
53
  async function getGoogleDriveToken() {
50
54
  const creds = findGoogleCredentials();
@@ -52,6 +56,18 @@ async function getGoogleDriveToken() {
52
56
  console.error(" [cloud] No Google credentials found (checked ~/.mailx/google-credentials.json and iflow package)");
53
57
  return null;
54
58
  }
59
+ // Delete stale token if scope changed (drive → drive.file or vice versa)
60
+ const tokenPath = path.join(GDRIVE_TOKEN_DIR, "token.json");
61
+ if (fs.existsSync(tokenPath)) {
62
+ try {
63
+ const existing = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
64
+ if (existing.scope && existing.scope !== GDRIVE_SCOPES) {
65
+ console.log(` [cloud] Scope changed — re-authenticating...`);
66
+ fs.unlinkSync(tokenPath);
67
+ }
68
+ }
69
+ catch { /* ignore parse errors */ }
70
+ }
55
71
  try {
56
72
  const token = await authenticateOAuth(creds, {
57
73
  scope: GDRIVE_SCOPES,
@@ -67,111 +83,97 @@ async function getGoogleDriveToken() {
67
83
  return null;
68
84
  }
69
85
  }
70
- // ── Google Drive API ──
71
- async function gDriveRead(filePath) {
86
+ // ── Google Drive API (folder-ID based) ──
87
+ /** Find the app-owned "mailx" folder, or create it. Returns folder ID. */
88
+ export async function gDriveFindOrCreateFolder() {
89
+ const token = await getGoogleDriveToken();
90
+ if (!token)
91
+ return null;
92
+ try {
93
+ // Search for existing folder (created by this OAuth client)
94
+ const query = `name='${GDRIVE_FOLDER_NAME}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
95
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
96
+ headers: { Authorization: `Bearer ${token}` },
97
+ });
98
+ if (!res.ok) {
99
+ console.error(` [cloud] gdrive folder search: ${res.status} ${res.statusText}`);
100
+ return null;
101
+ }
102
+ const data = await res.json();
103
+ if (data.files?.[0]) {
104
+ console.log(` [cloud] Found existing '${GDRIVE_FOLDER_NAME}' folder: ${data.files[0].id}`);
105
+ return data.files[0].id;
106
+ }
107
+ // Create folder
108
+ const createRes = await fetch("https://www.googleapis.com/drive/v3/files", {
109
+ method: "POST",
110
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
111
+ body: JSON.stringify({ name: GDRIVE_FOLDER_NAME, mimeType: "application/vnd.google-apps.folder" }),
112
+ });
113
+ if (!createRes.ok) {
114
+ console.error(` [cloud] gdrive folder create: ${createRes.status} ${createRes.statusText}`);
115
+ return null;
116
+ }
117
+ const created = await createRes.json();
118
+ console.log(` [cloud] Created '${GDRIVE_FOLDER_NAME}' folder: ${created.id}`);
119
+ return created.id;
120
+ }
121
+ catch (e) {
122
+ console.error(` [cloud] gdrive folder setup: ${e.message}`);
123
+ return null;
124
+ }
125
+ }
126
+ /** Read a file by name from a folder (by ID) */
127
+ async function gDriveReadFromFolder(folderId, fileName) {
72
128
  const token = await getGoogleDriveToken();
73
129
  if (!token) {
74
- console.error(` [cloud] gdrive read ${filePath}: no token`);
130
+ console.error(` [cloud] gdrive read ${fileName}: no token`);
75
131
  return null;
76
132
  }
77
133
  try {
78
- // Parse path: "home/.mailx/settings.jsonc" → find by folder structure
79
- const parts = filePath.split("/");
80
- const fileName = parts.pop();
81
- let parentId = null;
82
- // Navigate folder structure
83
- for (const folder of parts) {
84
- let query = `name='${folder}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
85
- if (parentId)
86
- query += ` and '${parentId}' in parents`;
87
- const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
88
- headers: { Authorization: `Bearer ${token}` },
89
- });
90
- if (!res.ok) {
91
- console.error(` [cloud] gdrive folder lookup '${folder}': ${res.status} ${res.statusText}`);
92
- return null;
93
- }
94
- const data = await res.json();
95
- if (!data.files?.[0]) {
96
- console.error(` [cloud] gdrive folder '${folder}' not found (drive.file scope can only see app-created files)`);
97
- return null;
98
- }
99
- parentId = data.files[0].id;
100
- }
101
- // Find the file
102
- let query = `name='${fileName}' and trashed=false`;
103
- if (parentId)
104
- query += ` and '${parentId}' in parents`;
105
- const fileRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
134
+ // Find file in folder
135
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
136
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
106
137
  headers: { Authorization: `Bearer ${token}` },
107
138
  });
108
- if (!fileRes.ok) {
109
- console.error(` [cloud] gdrive file lookup '${fileName}': ${fileRes.status} ${fileRes.statusText}`);
139
+ if (!res.ok) {
140
+ console.error(` [cloud] gdrive lookup '${fileName}': ${res.status}`);
110
141
  return null;
111
142
  }
112
- const fileData = await fileRes.json();
113
- const fileId = fileData.files?.[0]?.id;
114
- if (!fileId) {
115
- console.error(` [cloud] gdrive file '${fileName}' not found in ${parts.join("/")}`);
143
+ const data = await res.json();
144
+ const fileId = data.files?.[0]?.id;
145
+ if (!fileId)
116
146
  return null;
117
- }
118
147
  // Download content
119
148
  const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
120
149
  headers: { Authorization: `Bearer ${token}` },
121
150
  });
122
151
  if (!contentRes.ok) {
123
- console.error(` [cloud] gdrive download ${fileId}: ${contentRes.status} ${contentRes.statusText}`);
152
+ console.error(` [cloud] gdrive download ${fileName}: ${contentRes.status}`);
124
153
  return null;
125
154
  }
126
155
  return await contentRes.text();
127
156
  }
128
157
  catch (e) {
129
- console.error(` [cloud] gdrive read ${filePath}: ${e.message}`);
158
+ console.error(` [cloud] gdrive read ${fileName}: ${e.message}`);
130
159
  return null;
131
160
  }
132
161
  }
133
- async function gDriveWrite(filePath, content) {
162
+ /** Write a file by name to a folder (by ID) — creates or updates */
163
+ async function gDriveWriteToFolder(folderId, fileName, content) {
134
164
  const token = await getGoogleDriveToken();
135
165
  if (!token)
136
166
  return false;
137
167
  try {
138
- const parts = filePath.split("/");
139
- const fileName = parts.pop();
140
- // Ensure folder structure exists
141
- let parentId = null;
142
- for (const folder of parts) {
143
- let query = `name='${folder}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
144
- if (parentId)
145
- query += ` and '${parentId}' in parents`;
146
- const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
147
- headers: { Authorization: `Bearer ${token}` },
148
- });
149
- const data = await res.json();
150
- if (data.files?.[0]) {
151
- parentId = data.files[0].id;
152
- }
153
- else {
154
- // Create folder
155
- const createRes = await fetch("https://www.googleapis.com/drive/v3/files", {
156
- method: "POST",
157
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
158
- body: JSON.stringify({ name: folder, mimeType: "application/vnd.google-apps.folder", parents: parentId ? [parentId] : [] }),
159
- });
160
- const created = await createRes.json();
161
- parentId = created.id;
162
- }
163
- }
164
- // Check if file exists
165
- let query = `name='${fileName}' and trashed=false`;
166
- if (parentId)
167
- query += ` and '${parentId}' in parents`;
168
+ // Check if file exists in folder
169
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
168
170
  const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
169
171
  headers: { Authorization: `Bearer ${token}` },
170
172
  });
171
173
  const findData = await findRes.json();
172
174
  const existingId = findData.files?.[0]?.id;
173
175
  if (existingId) {
174
- // Update existing
176
+ // Update existing file
175
177
  const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
176
178
  method: "PATCH",
177
179
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
@@ -180,9 +182,9 @@ async function gDriveWrite(filePath, content) {
180
182
  return res.ok;
181
183
  }
182
184
  else {
183
- // Create new multipart upload with metadata
185
+ // Create new file in folder
184
186
  const boundary = "mailx_boundary_" + Date.now();
185
- const metadata = JSON.stringify({ name: fileName, parents: parentId ? [parentId] : [] });
187
+ const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
186
188
  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}--`;
187
189
  const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
188
190
  method: "POST",
@@ -192,18 +194,27 @@ async function gDriveWrite(filePath, content) {
192
194
  return res.ok;
193
195
  }
194
196
  }
195
- catch {
197
+ catch (e) {
198
+ console.error(` [cloud] gdrive write ${fileName}: ${e.message}`);
196
199
  return false;
197
200
  }
198
201
  }
199
- export function getCloudProvider(provider) {
202
+ /**
203
+ * Get a cloud file provider. For gdrive, pass the folder ID.
204
+ * Files are stored flat in the folder (no subdirectory navigation).
205
+ */
206
+ export function getCloudProvider(provider, folderId) {
200
207
  switch (provider) {
201
208
  case "google":
202
209
  case "gdrive":
210
+ if (!folderId) {
211
+ console.error(" [cloud] gdrive requires a folder ID — run initCloudConfig first");
212
+ return null;
213
+ }
203
214
  return {
204
- read: gDriveRead,
205
- write: gDriveWrite,
206
- exists: async (p) => (await gDriveRead(p)) !== null,
215
+ read: (fileName) => gDriveReadFromFolder(folderId, fileName),
216
+ write: (fileName, content) => gDriveWriteToFolder(folderId, fileName, content),
217
+ exists: async (fileName) => (await gDriveReadFromFolder(folderId, fileName)) !== null,
207
218
  };
208
219
  case "local":
209
220
  return {
@@ -90,8 +90,9 @@ export declare function getConfigDir(): string;
90
90
  export { getSharedDir };
91
91
  /** Initialize local config if it doesn't exist */
92
92
  export declare function initLocalConfig(sharedDir?: string, storePath?: string): void;
93
- /** Initialize config with Google Drive cloud storage */
94
- export declare function initCloudConfig(provider?: "gdrive", cloudPath?: string): void;
93
+ /** Initialize config with Google Drive cloud storage.
94
+ * Finds or creates the app-owned "mailx" folder on Drive and stores its ID. */
95
+ export declare function initCloudConfig(provider?: "gdrive"): Promise<void>;
95
96
  declare const DEFAULT_SETTINGS: MailxSettings;
96
97
  /** Get historyDays for an account: per-account override > system override > shared default */
97
98
  export declare function getHistoryDays(accountId?: string): number;
@@ -18,7 +18,7 @@
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { parse as parseJsonc } from "jsonc-parser";
21
- import { getCloudProvider } from "./cloud.js";
21
+ import { getCloudProvider, gDriveFindOrCreateFolder } from "./cloud.js";
22
22
  // ── Paths ──
23
23
  const LOCAL_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
24
24
  const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.jsonc");
@@ -110,14 +110,19 @@ function getSharedDir() {
110
110
  export async function cloudRead(filename) {
111
111
  if (!pendingCloudConfig)
112
112
  return null;
113
- const provider = getCloudProvider(pendingCloudConfig.provider);
113
+ // Ensure we have a folder ID
114
+ if (!pendingCloudConfig.folderId) {
115
+ pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
116
+ if (pendingCloudConfig.folderId)
117
+ saveFolderIdToConfig(pendingCloudConfig.folderId);
118
+ }
119
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
114
120
  if (!provider) {
115
121
  lastCloudError = `No cloud provider for ${pendingCloudConfig.provider}`;
116
122
  return null;
117
123
  }
118
- const cloudPath = `${pendingCloudConfig.path}/${filename}`;
119
- console.log(` [cloud] Reading ${cloudPath} via ${pendingCloudConfig.provider} API...`);
120
- const content = await provider.read(cloudPath);
124
+ console.log(` [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
125
+ const content = await provider.read(filename);
121
126
  if (content) {
122
127
  lastCloudError = null;
123
128
  // Cache locally
@@ -128,7 +133,7 @@ export async function cloudRead(filename) {
128
133
  catch { /* ignore cache write failure */ }
129
134
  }
130
135
  else {
131
- lastCloudError = `Could not read ${filename} from ${pendingCloudConfig.provider} (check credentials and drive.file scope)`;
136
+ lastCloudError = `Could not read ${filename} from ${pendingCloudConfig.provider} (check credentials)`;
132
137
  }
133
138
  return content;
134
139
  }
@@ -136,11 +141,27 @@ export async function cloudRead(filename) {
136
141
  export async function cloudWrite(filename, content) {
137
142
  if (!pendingCloudConfig)
138
143
  return false;
139
- const provider = getCloudProvider(pendingCloudConfig.provider);
144
+ // Ensure we have a folder ID
145
+ if (!pendingCloudConfig.folderId) {
146
+ pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
147
+ if (pendingCloudConfig.folderId)
148
+ saveFolderIdToConfig(pendingCloudConfig.folderId);
149
+ }
150
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
140
151
  if (!provider)
141
152
  return false;
142
- const cloudPath = `${pendingCloudConfig.path}/${filename}`;
143
- return provider.write(cloudPath, content);
153
+ return provider.write(filename, content);
154
+ }
155
+ /** Persist the discovered folder ID back to config.jsonc so we don't search again */
156
+ function saveFolderIdToConfig(folderId) {
157
+ try {
158
+ const config = readLocalConfig();
159
+ if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
160
+ config.sharedDir.folderId = folderId;
161
+ atomicWrite(LOCAL_CONFIG_PATH, config);
162
+ }
163
+ }
164
+ catch { /* non-critical */ }
144
165
  }
145
166
  /** Whether cloud API fallback is active */
146
167
  export function isCloudMode() {
@@ -526,21 +547,25 @@ export function initLocalConfig(sharedDir, storePath) {
526
547
  fs.mkdirSync(LOCAL_DIR, { recursive: true });
527
548
  atomicWrite(LOCAL_CONFIG_PATH, config);
528
549
  }
529
- /** Initialize config with Google Drive cloud storage */
530
- export function initCloudConfig(provider = "gdrive", cloudPath = "home/.mailx") {
550
+ /** Initialize config with Google Drive cloud storage.
551
+ * Finds or creates the app-owned "mailx" folder on Drive and stores its ID. */
552
+ export async function initCloudConfig(provider = "gdrive") {
531
553
  const existing = readLocalConfig();
532
554
  if (existing.sharedDir)
533
555
  return; // Already configured
556
+ // Find or create the "mailx" folder on Google Drive
557
+ const folderId = await gDriveFindOrCreateFolder();
558
+ const sharedDir = { provider, path: "mailx", folderId: folderId || undefined };
534
559
  const config = {
535
560
  ...existing,
536
- sharedDir: { provider, path: cloudPath },
561
+ sharedDir: sharedDir,
537
562
  storePath: existing.storePath || DEFAULT_STORE_PATH,
538
563
  };
539
564
  fs.mkdirSync(LOCAL_DIR, { recursive: true });
540
565
  atomicWrite(LOCAL_CONFIG_PATH, config);
541
566
  // Set up cloud API fallback immediately
542
- pendingCloudConfig = { provider, path: cloudPath };
543
- console.log(` Initialized cloud config: ${provider} ${cloudPath}`);
567
+ pendingCloudConfig = sharedDir;
568
+ console.log(` Initialized cloud config: ${provider} (folder ID: ${folderId || "pending"})`);
544
569
  }
545
570
  const DEFAULT_SETTINGS = {
546
571
  accounts: [],