@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 +118 -194
- package/client/app.js +8 -2
- package/client/components/folder-tree.js +3 -2
- package/package.json +1 -1
- package/packages/mailx-api/index.js +41 -7
- package/packages/mailx-server/index.js +16 -2
- package/packages/mailx-settings/cloud.d.ts +27 -6
- package/packages/mailx-settings/cloud.js +93 -182
- package/packages/mailx-settings/index.d.ts +5 -3
- package/packages/mailx-settings/index.js +56 -93
package/bin/mailx.js
CHANGED
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
* mailx -- email client
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* mailx
|
|
7
|
-
* mailx --server Start
|
|
8
|
-
* mailx --no-browser
|
|
9
|
-
* mailx --external Bind to all interfaces
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
333
|
-
const
|
|
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(`\
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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 (
|
|
375
|
-
// Save to Google Drive via API (
|
|
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("
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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 ||
|
|
502
|
+
if (setupMode || !hasConfig()) {
|
|
541
503
|
if (!setupMode)
|
|
542
504
|
console.log("No mailx configuration found.");
|
|
543
505
|
await runSetup();
|
|
544
506
|
}
|
|
545
|
-
if
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
569
|
-
await new Promise(() => { });
|
|
518
|
+
return;
|
|
570
519
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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 (
|
|
594
|
-
|
|
595
|
-
log(
|
|
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
|
-
|
|
616
|
-
log("
|
|
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}
|
|
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/
|
|
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": "
|
|
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
|
@@ -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,
|
|
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
|
-
//
|
|
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
|
-
|
|
122
|
-
|
|
149
|
+
const detected = await detectEmailProvider(domain);
|
|
150
|
+
if (detected?.cloud) {
|
|
151
|
+
await initCloudConfig(detected.cloud);
|
|
123
152
|
}
|
|
124
|
-
// Build account config
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
+
/** 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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
// ──
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
130
|
+
console.error(` [cloud] gdrive read ${fileName}: no token`);
|
|
167
131
|
return null;
|
|
168
132
|
}
|
|
169
133
|
try {
|
|
170
|
-
//
|
|
171
|
-
const
|
|
172
|
-
const
|
|
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 (!
|
|
201
|
-
console.error(` [cloud] gdrive
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
console.error(` [cloud] gdrive lookup '${fileName}': ${res.status}`);
|
|
202
141
|
return null;
|
|
203
142
|
}
|
|
204
|
-
const
|
|
205
|
-
const fileId =
|
|
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 ${
|
|
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 ${
|
|
158
|
+
console.error(` [cloud] gdrive read ${fileName}: ${e.message}`);
|
|
222
159
|
return null;
|
|
223
160
|
}
|
|
224
161
|
}
|
|
225
|
-
|
|
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
|
-
|
|
231
|
-
const fileName =
|
|
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
|
|
185
|
+
// Create new file in folder
|
|
276
186
|
const boundary = "mailx_boundary_" + Date.now();
|
|
277
|
-
const metadata = JSON.stringify({ name: fileName, parents:
|
|
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
|
-
|
|
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:
|
|
303
|
-
write:
|
|
304
|
-
exists: async (
|
|
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
|
|
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
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
186
|
+
// Not mounted — using API
|
|
184
187
|
if (pendingCloudConfig) {
|
|
185
|
-
const name = pendingCloudConfig.provider === "
|
|
186
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
592
|
-
|
|
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:
|
|
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 =
|
|
605
|
-
console.log(` Initialized cloud config: ${provider}
|
|
567
|
+
pendingCloudConfig = sharedDir;
|
|
568
|
+
console.log(` Initialized cloud config: ${provider} (folder ID: ${folderId || "pending"})`);
|
|
606
569
|
}
|
|
607
570
|
const DEFAULT_SETTINGS = {
|
|
608
571
|
accounts: [],
|