@bobfrankston/mailx 1.0.136 → 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/client/app.js CHANGED
@@ -904,10 +904,16 @@ fetch("/api/version").then(r => r.json()).then(d => {
904
904
  const el = document.getElementById("app-version");
905
905
  const storage = d.storage || {};
906
906
  const storageLabel = storage.provider && storage.provider !== "local"
907
- ? ` [${storage.provider}${storage.mode === "api" ? " API" : ""}]`
907
+ ? ` [${storage.provider}]`
908
908
  : "";
909
- if (el)
909
+ if (el) {
910
910
  el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
911
+ // Tooltip: show cloud path and access mode on hover
912
+ if (storage.provider && storage.provider !== "local") {
913
+ const modeDesc = storage.mode === "api" ? "API" : "mount";
914
+ el.title = `${storage.cloudPath || storage.provider} (${modeDesc})`;
915
+ }
916
+ }
911
917
  if (d.settingsError) {
912
918
  showAlert(d.settingsError, "settings-error");
913
919
  // Add repair button to the banner
@@ -367,8 +367,9 @@ async function loadFolderTree(container) {
367
367
  </form>
368
368
  <details style="margin-top:2rem;color:var(--color-text-muted)">
369
369
  <summary>Manual setup (advanced)</summary>
370
- <p style="margin-top:0.5rem">Create <code>~/.mailx/settings.jsonc</code> or point to shared settings:</p>
371
- <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": "~/OneDrive/home/.mailx" }</code>
370
+ <p style="margin-top:0.5rem">Create <code>~/.mailx/config.jsonc</code> with a cloud provider:</p>
371
+ <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": { "provider": "gdrive", "path": "home/.mailx" } }</code>
372
+ <p style="margin-top:0.5rem;font-size:0.85rem">Settings sync via Google Drive API (auto-configured for Gmail accounts).</p>
372
373
  </details>
373
374
  </div>`;
374
375
  // Wire up the setup form
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.136",
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",
@@ -3,8 +3,38 @@
3
3
  * Thin Express Router — delegates all logic to mailx-service.
4
4
  */
5
5
  import { Router } from "express";
6
+ import * as dns from "node:dns/promises";
6
7
  import { MailxService } from "@bobfrankston/mailx-service";
7
- import { loadAccounts, loadAccountsAsync, saveAccounts, initLocalConfig, initCloudConfig, loadSettings } from "@bobfrankston/mailx-settings";
8
+ import { loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadSettings } from "@bobfrankston/mailx-settings";
9
+ /** Detect email provider via MX records (Google Workspace, Microsoft 365 custom domains).
10
+ * Cloud storage is always gdrive (only for Google-hosted domains).
11
+ * Microsoft domains get correct IMAP/SMTP but no cloud auto-config. */
12
+ async function detectEmailProvider(domain) {
13
+ // Known domains — no MX lookup needed
14
+ const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
15
+ const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
16
+ if (GOOGLE_DOMAINS.includes(domain))
17
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
18
+ if (MS_DOMAINS.includes(domain))
19
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
20
+ // MX lookup for custom domains (Google Workspace, Microsoft 365)
21
+ try {
22
+ const records = await dns.resolveMx(domain);
23
+ for (const mx of records) {
24
+ const host = mx.exchange.toLowerCase();
25
+ if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
26
+ console.log(` [setup] MX for ${domain} → Google (${host})`);
27
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
28
+ }
29
+ if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
30
+ console.log(` [setup] MX for ${domain} → Microsoft (${host})`);
31
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
32
+ }
33
+ }
34
+ }
35
+ catch { /* DNS lookup failed — not critical */ }
36
+ return null;
37
+ }
8
38
  export function createApiRouter(db, imapManager) {
9
39
  const svc = new MailxService(db, imapManager);
10
40
  const router = Router();
@@ -114,17 +144,21 @@ export function createApiRouter(db, imapManager) {
114
144
  res.status(400).json({ error: "Email address required" });
115
145
  return;
116
146
  }
117
- // Ensure ~/.mailx exists
118
- initLocalConfig();
119
- // Default to gdrive for Gmail users (creates config.jsonc with gdrive provider)
147
+ // Detect provider via domain or MX records (Google Workspace, Microsoft 365 custom domains)
120
148
  const domain = email.split("@")[1]?.toLowerCase() || "";
121
- if (["gmail.com", "googlemail.com"].includes(domain)) {
122
- initCloudConfig("gdrive");
149
+ const detected = await detectEmailProvider(domain);
150
+ if (detected?.cloud) {
151
+ await initCloudConfig(detected.cloud);
123
152
  }
124
- // Build account config (normalizeAccount handles provider detection)
153
+ // Build account config
125
154
  const account = { email, name: name || email.split("@")[0] };
126
155
  if (password)
127
156
  account.password = password;
157
+ // For custom domains hosted on Google/Microsoft, set correct IMAP/SMTP servers
158
+ if (detected && !["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain)) {
159
+ account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
160
+ account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
161
+ }
128
162
  // Load existing accounts — try cloud API in case home/.mailx already exists on Drive
129
163
  let accounts = loadAccounts();
130
164
  if (accounts.length === 0) {
@@ -55,7 +55,7 @@ if (settings.accounts.length === 0) {
55
55
  console.log(` Loaded ${cloudAccounts.length} account(s) from cloud API`);
56
56
  }
57
57
  else {
58
- console.log(" No accounts configured. Open http://127.0.0.1:9333 to set up.");
58
+ console.log(" No accounts configured. Open http://127.0.0.1:9333 to add your email account.");
59
59
  }
60
60
  }
61
61
  const dbDir = getConfigDir();
@@ -100,7 +100,7 @@ app.get("/api/version", (req, res) => {
100
100
  const settingsError = (dbAccounts > 0 && imapAccounts === 0)
101
101
  ? "No accounts loaded from settings — showing stale data. Check accounts.jsonc on your cloud drive."
102
102
  : undefined;
103
- res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage, imapAccounts, settingsError });
103
+ res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage, imapAccounts, hasAccounts: imapAccounts > 0 || dbAccounts > 0, settingsError });
104
104
  });
105
105
  app.all("/info", (req, res) => {
106
106
  res.json({ version: SERVER_VERSION, uptime: Math.round(process.uptime()), port: PORT, imap: imapManager.useNativeClient ? "native" : "imapflow" });
@@ -203,10 +203,24 @@ const clients = new Set();
203
203
  function wireWebSocket() {
204
204
  wss.on("connection", (ws) => {
205
205
  clients.add(ws);
206
+ ws.__alive = true;
206
207
  const connected = { type: "connected" };
207
208
  ws.send(JSON.stringify(connected));
209
+ ws.on("pong", () => { ws.__alive = true; });
208
210
  ws.on("close", () => clients.delete(ws));
209
211
  });
212
+ // Heartbeat — detect dead connections, prevent CLOSE_WAIT zombie accumulation
213
+ setInterval(() => {
214
+ for (const ws of clients) {
215
+ if (ws.__alive === false) {
216
+ clients.delete(ws);
217
+ ws.terminate();
218
+ continue;
219
+ }
220
+ ws.__alive = false;
221
+ ws.ping();
222
+ }
223
+ }, 30000);
210
224
  }
211
225
  function broadcast(event) {
212
226
  const data = JSON.stringify(event);
@@ -1,14 +1,35 @@
1
1
  /**
2
- * Cloud storage abstraction for mailx settings.
3
- * Reads/writes settings files on OneDrive (Graph API) or Google Drive (Drive API)
4
- * when the cloud drive is not mounted locally.
5
- * 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.
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.
6
18
  */
7
- export type CloudProvider = "onedrive" | "gdrive" | "google" | "dropbox" | "local";
19
+ /** Find the app-owned "mailx" folder, or create it. Returns folder ID. */
20
+ export declare function gDriveFindOrCreateFolder(): Promise<string | null>;
21
+ export type CloudProvider = "gdrive" | "google" | "local";
8
22
  export interface CloudFile {
23
+ /** Read a file. For gdrive, path is just the filename (folder ID is implicit). */
9
24
  read(filePath: string): Promise<string | null>;
25
+ /** Write a file. For gdrive, path is just the filename. */
10
26
  write(filePath: string, content: string): Promise<boolean>;
27
+ /** Check if a file exists. */
11
28
  exists(filePath: string): Promise<boolean>;
12
29
  }
13
- export declare function getCloudProvider(provider: CloudProvider): 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;
14
35
  //# sourceMappingURL=cloud.d.ts.map
@@ -1,28 +1,33 @@
1
1
  /**
2
- * Cloud storage abstraction for mailx settings.
3
- * Reads/writes settings files on OneDrive (Graph API) or Google Drive (Drive API)
4
- * when the cloud drive is not mounted locally.
5
- * 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.
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.
6
18
  */
7
19
  import fs from "node:fs";
8
20
  import path from "node:path";
9
21
  import { authenticateOAuth } from "@bobfrankston/oauthsupport";
10
22
  const SETTINGS_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
11
23
  // ── Credentials ──
12
- // Microsoft Graph: needs app registration in Azure AD
13
- // Create at: https://portal.azure.com → App registrations → New → Desktop app
14
- const MS_CREDENTIALS_PATH = path.join(SETTINGS_DIR, "microsoft-credentials.json");
15
- const MS_TOKEN_DIR = path.join(SETTINGS_DIR, "tokens", "microsoft");
16
- const MS_SCOPES = "https://graph.microsoft.com/Files.ReadWrite offline_access";
17
24
  // Google Drive: reuse iflow's OAuth credentials (same Google Cloud project)
18
25
  function findGoogleCredentials() {
19
26
  // Check mailx local dir first, then iflow package
20
27
  const local = path.join(SETTINGS_DIR, "google-credentials.json");
21
28
  if (fs.existsSync(local))
22
29
  return local;
23
- // Try to find iflow's credentials via import.meta.resolve or node_modules walk
24
30
  try {
25
- // Walk up from this package to find iflow in node_modules
26
31
  let dir = import.meta.dirname;
27
32
  for (let i = 0; i < 5; i++) {
28
33
  for (const name of ["iflow-credentials.json", "credentials.json"]) {
@@ -40,31 +45,29 @@ function findGoogleCredentials() {
40
45
  return null;
41
46
  }
42
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.
43
50
  const GDRIVE_SCOPES = "https://www.googleapis.com/auth/drive.file";
51
+ const GDRIVE_FOLDER_NAME = "mailx";
44
52
  // ── Token helpers ──
45
- async function getMicrosoftToken() {
46
- if (!fs.existsSync(MS_CREDENTIALS_PATH))
47
- return null;
48
- try {
49
- const token = await authenticateOAuth(MS_CREDENTIALS_PATH, {
50
- scope: MS_SCOPES,
51
- tokenDirectory: MS_TOKEN_DIR,
52
- tokenFileName: "token.json",
53
- includeOfflineAccess: true,
54
- });
55
- return token?.access_token || null;
56
- }
57
- catch (e) {
58
- console.error(` [cloud] Microsoft auth failed: ${e.message}`);
59
- return null;
60
- }
61
- }
62
53
  async function getGoogleDriveToken() {
63
54
  const creds = findGoogleCredentials();
64
55
  if (!creds) {
65
56
  console.error(" [cloud] No Google credentials found (checked ~/.mailx/google-credentials.json and iflow package)");
66
57
  return null;
67
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
+ }
68
71
  try {
69
72
  const token = await authenticateOAuth(creds, {
70
73
  scope: GDRIVE_SCOPES,
@@ -80,190 +83,97 @@ async function getGoogleDriveToken() {
80
83
  return null;
81
84
  }
82
85
  }
83
- // ── OneDrive Graph API ──
84
- async function oneDriveRead(filePath) {
85
- const token = await getMicrosoftToken();
86
- if (!token)
87
- return null;
88
- try {
89
- const encoded = filePath.split("/").map(encodeURIComponent).join("/");
90
- const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}:/content`, {
91
- headers: { Authorization: `Bearer ${token}` },
92
- });
93
- if (!res.ok)
94
- return null;
95
- return await res.text();
96
- }
97
- catch {
98
- return null;
99
- }
100
- }
101
- async function oneDriveWrite(filePath, content) {
102
- const token = await getMicrosoftToken();
103
- if (!token)
104
- return false;
105
- try {
106
- const encoded = filePath.split("/").map(encodeURIComponent).join("/");
107
- const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}:/content`, {
108
- method: "PUT",
109
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
110
- body: content,
111
- });
112
- return res.ok;
113
- }
114
- catch {
115
- return false;
116
- }
117
- }
118
- async function oneDriveExists(filePath) {
119
- const token = await getMicrosoftToken();
120
- if (!token)
121
- return false;
122
- try {
123
- const encoded = filePath.split("/").map(encodeURIComponent).join("/");
124
- const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}`, {
125
- headers: { Authorization: `Bearer ${token}` },
126
- });
127
- return res.ok;
128
- }
129
- catch {
130
- return false;
131
- }
132
- }
133
- // ── Google Drive API ──
134
- async function gDriveFind(fileName, parentName) {
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() {
135
89
  const token = await getGoogleDriveToken();
136
90
  if (!token)
137
91
  return null;
138
92
  try {
139
- let query = `name='${fileName}' and trashed=false`;
140
- if (parentName) {
141
- // Find parent folder first
142
- const parentRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(`name='${parentName}' and mimeType='application/vnd.google-apps.folder' and trashed=false`)}&fields=files(id)`, {
143
- headers: { Authorization: `Bearer ${token}` },
144
- });
145
- if (parentRes.ok) {
146
- const data = await parentRes.json();
147
- if (data.files?.[0])
148
- query += ` and '${data.files[0].id}' in parents`;
149
- }
150
- }
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`;
151
95
  const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
152
96
  headers: { Authorization: `Bearer ${token}` },
153
97
  });
154
- if (!res.ok)
98
+ if (!res.ok) {
99
+ console.error(` [cloud] gdrive folder search: ${res.status} ${res.statusText}`);
155
100
  return null;
101
+ }
156
102
  const data = await res.json();
157
- return data.files?.[0]?.id || null;
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;
158
120
  }
159
- catch {
121
+ catch (e) {
122
+ console.error(` [cloud] gdrive folder setup: ${e.message}`);
160
123
  return null;
161
124
  }
162
125
  }
163
- async function gDriveRead(filePath) {
126
+ /** Read a file by name from a folder (by ID) */
127
+ async function gDriveReadFromFolder(folderId, fileName) {
164
128
  const token = await getGoogleDriveToken();
165
129
  if (!token) {
166
- console.error(` [cloud] gdrive read ${filePath}: no token`);
130
+ console.error(` [cloud] gdrive read ${fileName}: no token`);
167
131
  return null;
168
132
  }
169
133
  try {
170
- // Parse path: "home/.mailx/settings.jsonc" → find by folder structure
171
- const parts = filePath.split("/");
172
- const fileName = parts.pop();
173
- let parentId = null;
174
- // Navigate folder structure
175
- for (const folder of parts) {
176
- let query = `name='${folder}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
177
- if (parentId)
178
- query += ` and '${parentId}' in parents`;
179
- const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
180
- headers: { Authorization: `Bearer ${token}` },
181
- });
182
- if (!res.ok) {
183
- console.error(` [cloud] gdrive folder lookup '${folder}': ${res.status} ${res.statusText}`);
184
- return null;
185
- }
186
- const data = await res.json();
187
- if (!data.files?.[0]) {
188
- console.error(` [cloud] gdrive folder '${folder}' not found (drive.file scope can only see app-created files)`);
189
- return null;
190
- }
191
- parentId = data.files[0].id;
192
- }
193
- // Find the file
194
- let query = `name='${fileName}' and trashed=false`;
195
- if (parentId)
196
- query += ` and '${parentId}' in parents`;
197
- 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)`, {
198
137
  headers: { Authorization: `Bearer ${token}` },
199
138
  });
200
- if (!fileRes.ok) {
201
- console.error(` [cloud] gdrive file lookup '${fileName}': ${fileRes.status} ${fileRes.statusText}`);
139
+ if (!res.ok) {
140
+ console.error(` [cloud] gdrive lookup '${fileName}': ${res.status}`);
202
141
  return null;
203
142
  }
204
- const fileData = await fileRes.json();
205
- const fileId = fileData.files?.[0]?.id;
206
- if (!fileId) {
207
- 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)
208
146
  return null;
209
- }
210
147
  // Download content
211
148
  const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
212
149
  headers: { Authorization: `Bearer ${token}` },
213
150
  });
214
151
  if (!contentRes.ok) {
215
- console.error(` [cloud] gdrive download ${fileId}: ${contentRes.status} ${contentRes.statusText}`);
152
+ console.error(` [cloud] gdrive download ${fileName}: ${contentRes.status}`);
216
153
  return null;
217
154
  }
218
155
  return await contentRes.text();
219
156
  }
220
157
  catch (e) {
221
- console.error(` [cloud] gdrive read ${filePath}: ${e.message}`);
158
+ console.error(` [cloud] gdrive read ${fileName}: ${e.message}`);
222
159
  return null;
223
160
  }
224
161
  }
225
- 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) {
226
164
  const token = await getGoogleDriveToken();
227
165
  if (!token)
228
166
  return false;
229
167
  try {
230
- const parts = filePath.split("/");
231
- const fileName = parts.pop();
232
- // Ensure folder structure exists
233
- let parentId = null;
234
- for (const folder of parts) {
235
- let query = `name='${folder}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
236
- if (parentId)
237
- query += ` and '${parentId}' in parents`;
238
- const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
239
- headers: { Authorization: `Bearer ${token}` },
240
- });
241
- const data = await res.json();
242
- if (data.files?.[0]) {
243
- parentId = data.files[0].id;
244
- }
245
- else {
246
- // Create folder
247
- const createRes = await fetch("https://www.googleapis.com/drive/v3/files", {
248
- method: "POST",
249
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
250
- body: JSON.stringify({ name: folder, mimeType: "application/vnd.google-apps.folder", parents: parentId ? [parentId] : [] }),
251
- });
252
- const created = await createRes.json();
253
- parentId = created.id;
254
- }
255
- }
256
- // Check if file exists
257
- let query = `name='${fileName}' and trashed=false`;
258
- if (parentId)
259
- query += ` and '${parentId}' in parents`;
168
+ // Check if file exists in folder
169
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
260
170
  const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
261
171
  headers: { Authorization: `Bearer ${token}` },
262
172
  });
263
173
  const findData = await findRes.json();
264
174
  const existingId = findData.files?.[0]?.id;
265
175
  if (existingId) {
266
- // Update existing
176
+ // Update existing file
267
177
  const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
268
178
  method: "PATCH",
269
179
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
@@ -272,9 +182,9 @@ async function gDriveWrite(filePath, content) {
272
182
  return res.ok;
273
183
  }
274
184
  else {
275
- // Create new multipart upload with metadata
185
+ // Create new file in folder
276
186
  const boundary = "mailx_boundary_" + Date.now();
277
- const metadata = JSON.stringify({ name: fileName, parents: parentId ? [parentId] : [] });
187
+ const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
278
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}--`;
279
189
  const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
280
190
  method: "POST",
@@ -284,28 +194,28 @@ async function gDriveWrite(filePath, content) {
284
194
  return res.ok;
285
195
  }
286
196
  }
287
- catch {
197
+ catch (e) {
198
+ console.error(` [cloud] gdrive write ${fileName}: ${e.message}`);
288
199
  return false;
289
200
  }
290
201
  }
291
- 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) {
292
207
  switch (provider) {
293
- case "onedrive":
294
- return {
295
- read: oneDriveRead,
296
- write: oneDriveWrite,
297
- exists: oneDriveExists,
298
- };
299
208
  case "google":
300
209
  case "gdrive":
210
+ if (!folderId) {
211
+ console.error(" [cloud] gdrive requires a folder ID — run initCloudConfig first");
212
+ return null;
213
+ }
301
214
  return {
302
- read: gDriveRead,
303
- write: gDriveWrite,
304
- 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,
305
218
  };
306
- case "dropbox":
307
- // TODO: Dropbox API
308
- return null;
309
219
  case "local":
310
220
  return {
311
221
  read: async (p) => { try {
@@ -324,6 +234,7 @@ export function getCloudProvider(provider) {
324
234
  exists: async (p) => fs.existsSync(p),
325
235
  };
326
236
  default:
237
+ console.error(` [cloud] Provider "${provider}" not supported — only gdrive is available`);
327
238
  return null;
328
239
  }
329
240
  }
@@ -24,10 +24,11 @@ export declare function cloudRead(filename: string): Promise<string | null>;
24
24
  export declare function cloudWrite(filename: string, content: string): Promise<boolean>;
25
25
  /** Whether cloud API fallback is active */
26
26
  export declare function isCloudMode(): boolean;
27
- /** Get storage provider info for display (e.g. "OneDrive", "Google Drive", "local") */
27
+ /** Get storage provider info for display */
28
28
  export declare function getStorageInfo(): {
29
29
  provider: string;
30
30
  mode: "mount" | "api" | "local";
31
+ cloudPath?: string;
31
32
  cloudError?: string;
32
33
  };
33
34
  declare const DEFAULT_PREFERENCES: {
@@ -89,8 +90,9 @@ export declare function getConfigDir(): string;
89
90
  export { getSharedDir };
90
91
  /** Initialize local config if it doesn't exist */
91
92
  export declare function initLocalConfig(sharedDir?: string, storePath?: string): void;
92
- /** Initialize config with a cloud provider (e.g. gdrive for Gmail users) */
93
- export declare function initCloudConfig(provider: "gdrive" | "onedrive" | "dropbox", 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>;
94
96
  declare const DEFAULT_SETTINGS: MailxSettings;
95
97
  /** Get historyDays for an account: per-account override > system override > shared default */
96
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");
@@ -49,20 +49,11 @@ function readLocalConfig() {
49
49
  return {};
50
50
  return readJsonc(LOCAL_CONFIG_PATH) || {};
51
51
  }
52
- /** Resolve provider config to a filesystem path */
52
+ /** Resolve provider config to a filesystem path (checks for local Google Drive mount) */
53
53
  function resolveProvider(cfg) {
54
54
  const home = process.env.USERPROFILE || process.env.HOME || "";
55
55
  const rel = cfg.path; // e.g., "home/.mailx"
56
56
  switch (cfg.provider) {
57
- case "onedrive": {
58
- const candidates = [
59
- process.env.OneDrive && path.join(process.env.OneDrive, rel),
60
- process.env.OneDriveConsumer && path.join(process.env.OneDriveConsumer, rel),
61
- home && path.join(home, "OneDrive", rel),
62
- home && path.join(home, "onedrive", rel),
63
- ].filter(Boolean);
64
- return candidates.find(p => fs.existsSync(p));
65
- }
66
57
  case "google":
67
58
  case "gdrive": {
68
59
  const candidates = [
@@ -77,13 +68,6 @@ function resolveProvider(cfg) {
77
68
  }
78
69
  return candidates.filter(Boolean).find(p => fs.existsSync(p));
79
70
  }
80
- case "dropbox": {
81
- const candidates = [
82
- home && path.join(home, "Dropbox", rel),
83
- home && path.join(home, "dropbox", rel),
84
- ].filter(Boolean);
85
- return candidates.find(p => fs.existsSync(p));
86
- }
87
71
  case "local":
88
72
  return resolvePath(rel);
89
73
  default:
@@ -119,23 +103,26 @@ function getSharedDir() {
119
103
  }
120
104
  }
121
105
  }
122
- // Legacy: derive from settingsPath
123
- if (config.settingsPath)
124
- return path.dirname(resolvePath(config.settingsPath));
106
+ // Legacy settingsPath no longer used for shared dir — use loadLegacySettings() for reading only.
125
107
  return LOCAL_DIR;
126
108
  }
127
109
  /** Read a file via cloud API (when filesystem mount not available) */
128
110
  export async function cloudRead(filename) {
129
111
  if (!pendingCloudConfig)
130
112
  return null;
131
- 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);
132
120
  if (!provider) {
133
121
  lastCloudError = `No cloud provider for ${pendingCloudConfig.provider}`;
134
122
  return null;
135
123
  }
136
- const cloudPath = `${pendingCloudConfig.path}/${filename}`;
137
- console.log(` [cloud] Reading ${cloudPath} via ${pendingCloudConfig.provider} API...`);
138
- const content = await provider.read(cloudPath);
124
+ console.log(` [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
125
+ const content = await provider.read(filename);
139
126
  if (content) {
140
127
  lastCloudError = null;
141
128
  // Cache locally
@@ -146,7 +133,7 @@ export async function cloudRead(filename) {
146
133
  catch { /* ignore cache write failure */ }
147
134
  }
148
135
  else {
149
- 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)`;
150
137
  }
151
138
  return content;
152
139
  }
@@ -154,17 +141,33 @@ export async function cloudRead(filename) {
154
141
  export async function cloudWrite(filename, content) {
155
142
  if (!pendingCloudConfig)
156
143
  return false;
157
- 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);
158
151
  if (!provider)
159
152
  return false;
160
- const cloudPath = `${pendingCloudConfig.path}/${filename}`;
161
- 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 */ }
162
165
  }
163
166
  /** Whether cloud API fallback is active */
164
167
  export function isCloudMode() {
165
168
  return pendingCloudConfig !== null;
166
169
  }
167
- /** Get storage provider info for display (e.g. "OneDrive", "Google Drive", "local") */
170
+ /** Get storage provider info for display */
168
171
  export function getStorageInfo() {
169
172
  const config = readLocalConfig();
170
173
  if (config.sharedDir) {
@@ -172,20 +175,18 @@ export function getStorageInfo() {
172
175
  for (const entry of entries) {
173
176
  const resolved = resolveSharedEntry(entry);
174
177
  if (resolved && resolved !== LOCAL_DIR) {
175
- // Mounted cloud drive
176
- const name = typeof entry === "string" ? "cloud" :
177
- entry.provider === "onedrive" ? "OneDrive" :
178
- (entry.provider === "gdrive" || entry.provider === "google") ? "Google Drive" :
179
- entry.provider === "dropbox" ? "Dropbox" : entry.provider;
180
- return { provider: name, mode: "mount" };
178
+ if (typeof entry === "string") {
179
+ // Legacy string path filesystem only, no cloud label
180
+ return { provider: "local", mode: "local", cloudPath: resolved };
181
+ }
182
+ const name = (entry.provider === "gdrive" || entry.provider === "google") ? "gdrive" : entry.provider;
183
+ return { provider: name, mode: "mount", cloudPath: entry.path };
181
184
  }
182
185
  }
183
- // Not mounted but using API fallback
186
+ // Not mounted using API
184
187
  if (pendingCloudConfig) {
185
- const name = pendingCloudConfig.provider === "onedrive" ? "OneDrive" :
186
- (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "Google Drive" :
187
- pendingCloudConfig.provider === "dropbox" ? "Dropbox" : pendingCloudConfig.provider;
188
- return { provider: name, mode: "api", cloudError: lastCloudError || undefined };
188
+ const name = (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "gdrive" : pendingCloudConfig.provider;
189
+ return { provider: name, mode: "api", cloudPath: pendingCloudConfig.path, cloudError: lastCloudError || undefined };
189
190
  }
190
191
  }
191
192
  return { provider: "local", mode: "local" };
@@ -528,58 +529,16 @@ export function getConfigDir() {
528
529
  }
529
530
  /** Get the shared settings directory */
530
531
  export { getSharedDir };
531
- /** Auto-detect shared settings on OneDrive or common cloud sync locations */
532
- function detectSharedDir() {
533
- const home = process.env.USERPROFILE || process.env.HOME || "";
534
- // Scan common drive letters for Google Drive mount
535
- const driveLetters = [];
536
- if (process.platform === "win32") {
537
- for (const letter of ["G", "H", "I", "J", "K"]) {
538
- driveLetters.push(path.join(`${letter}:`, "My Drive", "home", ".mailx"));
539
- driveLetters.push(path.join(`${letter}:`, "My Drive", "mailx"));
540
- }
541
- }
542
- const candidates = [
543
- // OneDrive (Windows env vars)
544
- process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx"),
545
- process.env.OneDriveConsumer && path.join(process.env.OneDriveConsumer, "home", ".mailx"),
546
- // OneDrive (standard paths)
547
- home && path.join(home, "OneDrive", "home", ".mailx"),
548
- home && path.join(home, "onedrive", "home", ".mailx"),
549
- // Google Drive for Desktop — home/.mailx convention (matches OneDrive)
550
- home && path.join(home, "Google Drive", "My Drive", "home", ".mailx"),
551
- home && path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx"),
552
- // Google Drive — also check mailx at root
553
- home && path.join(home, "Google Drive", "My Drive", "mailx"),
554
- home && path.join(home, "Google Drive Streaming", "My Drive", "mailx"),
555
- // Google Drive mount letters (Windows)
556
- ...driveLetters,
557
- // Dropbox
558
- home && path.join(home, "Dropbox", ".mailx"),
559
- home && path.join(home, "dropbox", ".mailx"),
560
- ].filter(Boolean);
561
- for (const dir of candidates) {
562
- if (fs.existsSync(path.join(dir, "settings.jsonc")) || fs.existsSync(path.join(dir, "accounts.jsonc"))) {
563
- return dir;
564
- }
565
- }
566
- return undefined;
567
- }
532
+ // detectSharedDir() removed cloud storage is configured via API (gdrive/onedrive),
533
+ // not auto-detected from filesystem mounts. Setup form triggers initCloudConfig().
568
534
  /** Initialize local config if it doesn't exist */
569
535
  export function initLocalConfig(sharedDir, storePath) {
570
536
  if (fs.existsSync(LOCAL_CONFIG_PATH) && !sharedDir && !storePath)
571
537
  return;
572
538
  const existing = readLocalConfig();
573
- // Auto-detect shared settings if not configured
574
- let resolvedSharedDir = sharedDir || existing.sharedDir;
575
- if (!resolvedSharedDir && existing.settingsPath) {
576
- resolvedSharedDir = path.dirname(existing.settingsPath);
577
- }
578
- if (!resolvedSharedDir) {
579
- resolvedSharedDir = detectSharedDir();
580
- if (resolvedSharedDir)
581
- console.log(` Auto-detected shared settings: ${resolvedSharedDir}`);
582
- }
539
+ // Use explicit sharedDir or preserve existing — no auto-detection.
540
+ // Cloud storage is configured when user adds an account (initCloudConfig).
541
+ const resolvedSharedDir = sharedDir || existing.sharedDir;
583
542
  const config = {
584
543
  ...existing,
585
544
  sharedDir: resolvedSharedDir,
@@ -588,21 +547,25 @@ export function initLocalConfig(sharedDir, storePath) {
588
547
  fs.mkdirSync(LOCAL_DIR, { recursive: true });
589
548
  atomicWrite(LOCAL_CONFIG_PATH, config);
590
549
  }
591
- /** Initialize config with a cloud provider (e.g. gdrive for Gmail users) */
592
- export function initCloudConfig(provider, 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") {
593
553
  const existing = readLocalConfig();
594
554
  if (existing.sharedDir)
595
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 };
596
559
  const config = {
597
560
  ...existing,
598
- sharedDir: { provider, path: cloudPath },
561
+ sharedDir: sharedDir,
599
562
  storePath: existing.storePath || DEFAULT_STORE_PATH,
600
563
  };
601
564
  fs.mkdirSync(LOCAL_DIR, { recursive: true });
602
565
  atomicWrite(LOCAL_CONFIG_PATH, config);
603
566
  // Set up cloud API fallback immediately
604
- pendingCloudConfig = { provider, path: cloudPath };
605
- console.log(` Initialized cloud config: ${provider} ${cloudPath}`);
567
+ pendingCloudConfig = sharedDir;
568
+ console.log(` Initialized cloud config: ${provider} (folder ID: ${folderId || "pending"})`);
606
569
  }
607
570
  const DEFAULT_SETTINGS = {
608
571
  accounts: [],