@bobfrankston/mailx 1.0.40 → 1.0.42
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
|
@@ -25,13 +25,16 @@ const serverMode = hasFlag("server");
|
|
|
25
25
|
const noBrowser = hasFlag("no-browser");
|
|
26
26
|
const verbose = hasFlag("verbose");
|
|
27
27
|
|
|
28
|
+
const setupMode = hasFlag("setup");
|
|
29
|
+
const addMode = hasFlag("add");
|
|
30
|
+
|
|
28
31
|
// Validate arguments
|
|
29
|
-
const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version"];
|
|
32
|
+
const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add"];
|
|
30
33
|
for (const arg of args) {
|
|
31
34
|
const flag = arg.replace(/^--?/, "");
|
|
32
35
|
if (arg.startsWith("-") && !knownFlags.includes(flag)) {
|
|
33
36
|
console.error(`Unknown option: ${arg}`);
|
|
34
|
-
console.error("Usage: mailx [-server] [-verbose] [-kill] [-v] [-no-browser] [-external]");
|
|
37
|
+
console.error("Usage: mailx [-server] [-verbose] [-kill] [-v] [-setup] [-no-browser] [-external]");
|
|
35
38
|
process.exit(1);
|
|
36
39
|
}
|
|
37
40
|
}
|
|
@@ -125,10 +128,236 @@ function openBrowser(url) {
|
|
|
125
128
|
});
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
function prompt(question) {
|
|
132
|
+
const readline = require("readline");
|
|
133
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
134
|
+
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Detect mounted cloud drives that might have settings */
|
|
138
|
+
function findCloudSettings() {
|
|
139
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
140
|
+
const checks = [
|
|
141
|
+
// OneDrive
|
|
142
|
+
{ provider: "onedrive", dir: process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx") },
|
|
143
|
+
{ provider: "onedrive", dir: path.join(home, "OneDrive", "home", ".mailx") },
|
|
144
|
+
{ provider: "onedrive", dir: path.join(home, "onedrive", "home", ".mailx") },
|
|
145
|
+
// Google Drive
|
|
146
|
+
{ provider: "gdrive", dir: path.join(home, "Google Drive", "My Drive", "home", ".mailx") },
|
|
147
|
+
{ provider: "gdrive", dir: path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx") },
|
|
148
|
+
// Dropbox
|
|
149
|
+
{ provider: "dropbox", dir: path.join(home, "Dropbox", "home", ".mailx") },
|
|
150
|
+
];
|
|
151
|
+
// Drive letters on Windows
|
|
152
|
+
if (process.platform === "win32") {
|
|
153
|
+
for (const letter of ["G", "H", "I", "J", "K"]) {
|
|
154
|
+
checks.push({ provider: "gdrive", dir: path.join(`${letter}:`, "My Drive", "home", ".mailx") });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const c of checks) {
|
|
158
|
+
if (c.dir && fs.existsSync(c.dir) && (fs.existsSync(path.join(c.dir, "settings.jsonc")) || fs.existsSync(path.join(c.dir, "accounts.jsonc")))) {
|
|
159
|
+
return c;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Check if mailx is configured (has accounts) */
|
|
166
|
+
function hasConfig() {
|
|
167
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
168
|
+
const mailxDir = path.join(home, ".mailx");
|
|
169
|
+
// Check local settings
|
|
170
|
+
for (const f of ["settings.jsonc", "accounts.jsonc", "config.jsonc"]) {
|
|
171
|
+
if (fs.existsSync(path.join(mailxDir, f))) return true;
|
|
172
|
+
}
|
|
173
|
+
// Check cloud drives
|
|
174
|
+
return findCloudSettings() !== null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Try to auto-discover mail server settings from email domain */
|
|
178
|
+
async function autoDiscover(domain) {
|
|
179
|
+
// 1. Try Thunderbird ISPDB
|
|
180
|
+
try {
|
|
181
|
+
const res = await fetch(`https://autoconfig.thunderbird.net/v1.1/${domain}`, { signal: AbortSignal.timeout(5000) });
|
|
182
|
+
if (res.ok) {
|
|
183
|
+
const xml = await res.text();
|
|
184
|
+
const imap = xml.match(/<incomingServer type="imap">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
|
|
185
|
+
const smtp = xml.match(/<outgoingServer type="smtp">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
|
|
186
|
+
if (imap && smtp) {
|
|
187
|
+
return {
|
|
188
|
+
imap: { host: imap[1], port: parseInt(imap[2]), auth: imap[3].includes("OAuth2") ? "oauth2" : "password" },
|
|
189
|
+
smtp: { host: smtp[1], port: parseInt(smtp[2]), auth: smtp[3].includes("OAuth2") ? "oauth2" : "password" },
|
|
190
|
+
source: "ISPDB",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch { /* timeout or not found */ }
|
|
195
|
+
|
|
196
|
+
// 2. Try DNS SRV records
|
|
197
|
+
try {
|
|
198
|
+
const dns = await import("node:dns/promises");
|
|
199
|
+
const imapSrv = await dns.resolveSrv(`_imaps._tcp.${domain}`).catch(() => null);
|
|
200
|
+
const smtpSrv = await dns.resolveSrv(`_submission._tcp.${domain}`).catch(() => null);
|
|
201
|
+
if (imapSrv?.[0] && smtpSrv?.[0]) {
|
|
202
|
+
return {
|
|
203
|
+
imap: { host: imapSrv[0].name, port: imapSrv[0].port, auth: "password" },
|
|
204
|
+
smtp: { host: smtpSrv[0].name, port: smtpSrv[0].port, auth: "password" },
|
|
205
|
+
source: "DNS SRV",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
} catch { /* DNS failed */ }
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Prompt user for account details, auto-discover where possible */
|
|
214
|
+
async function promptForAccount(intro) {
|
|
215
|
+
if (intro) console.log(intro);
|
|
216
|
+
const email = await prompt("Email address (or 'skip'): ");
|
|
217
|
+
if (!email || email.toLowerCase() === "skip") return null;
|
|
218
|
+
|
|
219
|
+
const domain = email.split("@")[1]?.toLowerCase() || "";
|
|
220
|
+
const knownOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"];
|
|
221
|
+
|
|
222
|
+
if (knownOAuth.includes(domain)) {
|
|
223
|
+
console.log(` ${domain}: OAuth2 — no password needed. Browser will prompt for authorization.`);
|
|
224
|
+
return { email };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Try auto-discovery
|
|
228
|
+
console.log(` Looking up mail servers for ${domain}...`);
|
|
229
|
+
const discovered = await autoDiscover(domain);
|
|
230
|
+
|
|
231
|
+
if (discovered) {
|
|
232
|
+
console.log(` Found via ${discovered.source}: IMAP ${discovered.imap.host}:${discovered.imap.port}, SMTP ${discovered.smtp.host}:${discovered.smtp.port}`);
|
|
233
|
+
if (discovered.imap.auth === "oauth2") {
|
|
234
|
+
console.log(" OAuth2 authentication — browser will prompt for authorization.");
|
|
235
|
+
return { email };
|
|
236
|
+
}
|
|
237
|
+
const password = await prompt("Password: ");
|
|
238
|
+
return {
|
|
239
|
+
email, password,
|
|
240
|
+
imap: { host: discovered.imap.host, port: discovered.imap.port },
|
|
241
|
+
smtp: { host: discovered.smtp.host, port: discovered.smtp.port },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Manual fallback
|
|
246
|
+
console.log(" Could not auto-detect servers. Enter manually:");
|
|
247
|
+
const password = await prompt("Password: ");
|
|
248
|
+
const imapHost = await prompt(`IMAP host [imap.${domain}]: `) || `imap.${domain}`;
|
|
249
|
+
const smtpHost = await prompt(`SMTP host [smtp.${domain}]: `) || `smtp.${domain}`;
|
|
250
|
+
return {
|
|
251
|
+
email, password,
|
|
252
|
+
imap: { host: imapHost },
|
|
253
|
+
smtp: { host: smtpHost },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Interactive first-time setup */
|
|
258
|
+
async function runSetup() {
|
|
259
|
+
console.log("\nmailx — first-time setup\n");
|
|
260
|
+
|
|
261
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
262
|
+
const mailxDir = path.join(home, ".mailx");
|
|
263
|
+
|
|
264
|
+
// Check for existing cloud settings
|
|
265
|
+
const cloud = findCloudSettings();
|
|
266
|
+
if (cloud) {
|
|
267
|
+
console.log(`Found existing settings on ${cloud.provider}: ${cloud.dir}`);
|
|
268
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
269
|
+
const config = { sharedDir: { provider: cloud.provider, path: "home/.mailx" } };
|
|
270
|
+
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
271
|
+
console.log(`Created ~/.mailx/config.jsonc pointing to ${cloud.provider}`);
|
|
272
|
+
console.log("Setup complete. Starting mailx...\n");
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// No cloud settings found — prompt for email
|
|
277
|
+
console.log("No existing settings found on OneDrive, Google Drive, or Dropbox.\n");
|
|
278
|
+
const account = await promptForAccount("Gmail is recommended as the first account (provides contacts + cloud sync).\n");
|
|
279
|
+
if (!account) {
|
|
280
|
+
console.log(`\nCreate settings manually at: ${path.join(mailxDir, "settings.jsonc")}`);
|
|
281
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const name = await prompt(`Your name (for From: header) [${account.email.split("@")[0]}]: `) || account.email.split("@")[0];
|
|
286
|
+
|
|
287
|
+
const settings = {
|
|
288
|
+
name,
|
|
289
|
+
accounts: [account],
|
|
290
|
+
ui: { theme: "system" },
|
|
291
|
+
sync: { intervalMinutes: 5, historyDays: 0 },
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Detect mounted cloud drive to save settings to
|
|
295
|
+
const mountedDrive = findMountedDrive();
|
|
296
|
+
if (mountedDrive) {
|
|
297
|
+
console.log(`\nSaving settings to ${mountedDrive.provider} at ${mountedDrive.dir}...`);
|
|
298
|
+
fs.mkdirSync(mountedDrive.dir, { recursive: true });
|
|
299
|
+
fs.writeFileSync(path.join(mountedDrive.dir, "settings.jsonc"), JSON.stringify(settings, null, 2));
|
|
300
|
+
// Create local config pointing to cloud
|
|
301
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
302
|
+
const config = { sharedDir: { provider: mountedDrive.provider, path: "home/.mailx" } };
|
|
303
|
+
fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
|
|
304
|
+
console.log("Settings saved to cloud drive + local config created.");
|
|
305
|
+
} else {
|
|
306
|
+
// Save locally
|
|
307
|
+
console.log(`\nSaving settings to ${mailxDir}...`);
|
|
308
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
309
|
+
fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
console.log("Setup complete. Starting mailx...\n");
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Find a mounted cloud drive (for saving new settings) */
|
|
317
|
+
function findMountedDrive() {
|
|
318
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
319
|
+
const checks = [
|
|
320
|
+
{ provider: "onedrive", base: process.env.OneDrive, dir: process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx") },
|
|
321
|
+
{ provider: "onedrive", base: path.join(home, "OneDrive"), dir: path.join(home, "OneDrive", "home", ".mailx") },
|
|
322
|
+
{ provider: "onedrive", base: path.join(home, "onedrive"), dir: path.join(home, "onedrive", "home", ".mailx") },
|
|
323
|
+
{ provider: "gdrive", base: path.join(home, "Google Drive", "My Drive"), dir: path.join(home, "Google Drive", "My Drive", "home", ".mailx") },
|
|
324
|
+
{ provider: "gdrive", base: path.join(home, "Google Drive Streaming", "My Drive"), dir: path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx") },
|
|
325
|
+
{ provider: "dropbox", base: path.join(home, "Dropbox"), dir: path.join(home, "Dropbox", "home", ".mailx") },
|
|
326
|
+
];
|
|
327
|
+
for (const c of checks) {
|
|
328
|
+
if (c.base && fs.existsSync(c.base)) return c;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
128
333
|
async function main() {
|
|
129
334
|
log(`Platform: ${process.platform} ${process.arch}`);
|
|
130
335
|
log(`Node: ${process.version}`);
|
|
131
|
-
log(`Mode: ${serverMode ? "server" : "auto-detect"}`);
|
|
336
|
+
log(`Mode: ${serverMode ? "server" : setupMode ? "setup" : "auto-detect"}`);
|
|
337
|
+
|
|
338
|
+
// Add account to existing config
|
|
339
|
+
if (addMode) {
|
|
340
|
+
const account = await promptForAccount();
|
|
341
|
+
if (account) {
|
|
342
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
343
|
+
const mailxDir = path.join(home, ".mailx");
|
|
344
|
+
const settingsPath = path.join(mailxDir, "settings.jsonc");
|
|
345
|
+
let settings;
|
|
346
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8").replace(/\r/g, "").replace(/\/\/.*/g, "")); } catch { settings = { accounts: [] }; }
|
|
347
|
+
if (!settings.accounts) settings.accounts = [];
|
|
348
|
+
settings.accounts.push(account);
|
|
349
|
+
fs.mkdirSync(mailxDir, { recursive: true });
|
|
350
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
351
|
+
console.log(`Added ${account.email} to settings. Restart mailx to connect.`);
|
|
352
|
+
}
|
|
353
|
+
process.exit(0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Auto-detect first run — enter setup if no config exists
|
|
357
|
+
if (setupMode || (!serverMode && !hasConfig())) {
|
|
358
|
+
if (!setupMode) console.log("No mailx configuration found.");
|
|
359
|
+
await runSetup();
|
|
360
|
+
}
|
|
132
361
|
|
|
133
362
|
if (serverMode) {
|
|
134
363
|
// Server mode — Express + WebSocket, open browser
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.42",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.22",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.6",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.11",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.2",
|
|
@@ -0,0 +1,14 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
export type CloudProvider = "onedrive" | "gdrive" | "dropbox" | "local";
|
|
8
|
+
export interface CloudFile {
|
|
9
|
+
read(filePath: string): Promise<string | null>;
|
|
10
|
+
write(filePath: string, content: string): Promise<boolean>;
|
|
11
|
+
exists(filePath: string): Promise<boolean>;
|
|
12
|
+
}
|
|
13
|
+
export declare function getCloudProvider(provider: CloudProvider): CloudFile | null;
|
|
14
|
+
//# sourceMappingURL=cloud.d.ts.map
|
|
@@ -0,0 +1,308 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { authenticateOAuth } from "@bobfrankston/oauthsupport";
|
|
10
|
+
const SETTINGS_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
|
|
11
|
+
// ── 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
|
+
// Google Drive: reuse iflow's OAuth credentials (same Google Cloud project)
|
|
18
|
+
function findGoogleCredentials() {
|
|
19
|
+
// Check mailx local dir first, then iflow package
|
|
20
|
+
const local = path.join(SETTINGS_DIR, "google-credentials.json");
|
|
21
|
+
if (fs.existsSync(local))
|
|
22
|
+
return local;
|
|
23
|
+
// Try to find iflow's credentials
|
|
24
|
+
try {
|
|
25
|
+
const iflowPkg = require.resolve("@bobfrankston/iflow/package.json");
|
|
26
|
+
const iflowDir = path.dirname(iflowPkg);
|
|
27
|
+
for (const name of ["iflow-credentials.json", "credentials.json"]) {
|
|
28
|
+
const p = path.join(iflowDir, name);
|
|
29
|
+
if (fs.existsSync(p))
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { /* iflow not installed */ }
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const GDRIVE_TOKEN_DIR = path.join(SETTINGS_DIR, "tokens", "gdrive");
|
|
37
|
+
const GDRIVE_SCOPES = "https://www.googleapis.com/auth/drive.file";
|
|
38
|
+
// ── Token helpers ──
|
|
39
|
+
async function getMicrosoftToken() {
|
|
40
|
+
if (!fs.existsSync(MS_CREDENTIALS_PATH))
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const token = await authenticateOAuth(MS_CREDENTIALS_PATH, {
|
|
44
|
+
scope: MS_SCOPES,
|
|
45
|
+
tokenDirectory: MS_TOKEN_DIR,
|
|
46
|
+
tokenFileName: "token.json",
|
|
47
|
+
includeOfflineAccess: true,
|
|
48
|
+
});
|
|
49
|
+
return token?.access_token || null;
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
console.error(` [cloud] Microsoft auth failed: ${e.message}`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function getGoogleDriveToken() {
|
|
57
|
+
const creds = findGoogleCredentials();
|
|
58
|
+
if (!creds)
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
const token = await authenticateOAuth(creds, {
|
|
62
|
+
scope: GDRIVE_SCOPES,
|
|
63
|
+
tokenDirectory: GDRIVE_TOKEN_DIR,
|
|
64
|
+
tokenFileName: "token.json",
|
|
65
|
+
credentialsKey: "installed",
|
|
66
|
+
includeOfflineAccess: true,
|
|
67
|
+
});
|
|
68
|
+
return token?.access_token || null;
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
console.error(` [cloud] Google Drive auth failed: ${e.message}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ── OneDrive Graph API ──
|
|
76
|
+
async function oneDriveRead(filePath) {
|
|
77
|
+
const token = await getMicrosoftToken();
|
|
78
|
+
if (!token)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const encoded = filePath.split("/").map(encodeURIComponent).join("/");
|
|
82
|
+
const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}:/content`, {
|
|
83
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
return null;
|
|
87
|
+
return await res.text();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function oneDriveWrite(filePath, content) {
|
|
94
|
+
const token = await getMicrosoftToken();
|
|
95
|
+
if (!token)
|
|
96
|
+
return false;
|
|
97
|
+
try {
|
|
98
|
+
const encoded = filePath.split("/").map(encodeURIComponent).join("/");
|
|
99
|
+
const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}:/content`, {
|
|
100
|
+
method: "PUT",
|
|
101
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
102
|
+
body: content,
|
|
103
|
+
});
|
|
104
|
+
return res.ok;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function oneDriveExists(filePath) {
|
|
111
|
+
const token = await getMicrosoftToken();
|
|
112
|
+
if (!token)
|
|
113
|
+
return false;
|
|
114
|
+
try {
|
|
115
|
+
const encoded = filePath.split("/").map(encodeURIComponent).join("/");
|
|
116
|
+
const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}`, {
|
|
117
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
118
|
+
});
|
|
119
|
+
return res.ok;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// ── Google Drive API ──
|
|
126
|
+
async function gDriveFind(fileName, parentName) {
|
|
127
|
+
const token = await getGoogleDriveToken();
|
|
128
|
+
if (!token)
|
|
129
|
+
return null;
|
|
130
|
+
try {
|
|
131
|
+
let query = `name='${fileName}' and trashed=false`;
|
|
132
|
+
if (parentName) {
|
|
133
|
+
// Find parent folder first
|
|
134
|
+
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)`, {
|
|
135
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
136
|
+
});
|
|
137
|
+
if (parentRes.ok) {
|
|
138
|
+
const data = await parentRes.json();
|
|
139
|
+
if (data.files?.[0])
|
|
140
|
+
query += ` and '${data.files[0].id}' in parents`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
|
|
144
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
145
|
+
});
|
|
146
|
+
if (!res.ok)
|
|
147
|
+
return null;
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
return data.files?.[0]?.id || null;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function gDriveRead(filePath) {
|
|
156
|
+
const token = await getGoogleDriveToken();
|
|
157
|
+
if (!token)
|
|
158
|
+
return null;
|
|
159
|
+
try {
|
|
160
|
+
// Parse path: "home/.mailx/settings.jsonc" → find by folder structure
|
|
161
|
+
const parts = filePath.split("/");
|
|
162
|
+
const fileName = parts.pop();
|
|
163
|
+
let parentId = null;
|
|
164
|
+
// Navigate folder structure
|
|
165
|
+
for (const folder of parts) {
|
|
166
|
+
let query = `name='${folder}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
|
|
167
|
+
if (parentId)
|
|
168
|
+
query += ` and '${parentId}' in parents`;
|
|
169
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
170
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok)
|
|
173
|
+
return null;
|
|
174
|
+
const data = await res.json();
|
|
175
|
+
if (!data.files?.[0])
|
|
176
|
+
return null;
|
|
177
|
+
parentId = data.files[0].id;
|
|
178
|
+
}
|
|
179
|
+
// Find the file
|
|
180
|
+
let query = `name='${fileName}' and trashed=false`;
|
|
181
|
+
if (parentId)
|
|
182
|
+
query += ` and '${parentId}' in parents`;
|
|
183
|
+
const fileRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
184
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
185
|
+
});
|
|
186
|
+
if (!fileRes.ok)
|
|
187
|
+
return null;
|
|
188
|
+
const fileData = await fileRes.json();
|
|
189
|
+
const fileId = fileData.files?.[0]?.id;
|
|
190
|
+
if (!fileId)
|
|
191
|
+
return null;
|
|
192
|
+
// Download content
|
|
193
|
+
const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
|
|
194
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
195
|
+
});
|
|
196
|
+
if (!contentRes.ok)
|
|
197
|
+
return null;
|
|
198
|
+
return await contentRes.text();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function gDriveWrite(filePath, content) {
|
|
205
|
+
const token = await getGoogleDriveToken();
|
|
206
|
+
if (!token)
|
|
207
|
+
return false;
|
|
208
|
+
try {
|
|
209
|
+
const parts = filePath.split("/");
|
|
210
|
+
const fileName = parts.pop();
|
|
211
|
+
// Ensure folder structure exists
|
|
212
|
+
let parentId = null;
|
|
213
|
+
for (const folder of parts) {
|
|
214
|
+
let query = `name='${folder}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
|
|
215
|
+
if (parentId)
|
|
216
|
+
query += ` and '${parentId}' in parents`;
|
|
217
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
218
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
219
|
+
});
|
|
220
|
+
const data = await res.json();
|
|
221
|
+
if (data.files?.[0]) {
|
|
222
|
+
parentId = data.files[0].id;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Create folder
|
|
226
|
+
const createRes = await fetch("https://www.googleapis.com/drive/v3/files", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
229
|
+
body: JSON.stringify({ name: folder, mimeType: "application/vnd.google-apps.folder", parents: parentId ? [parentId] : [] }),
|
|
230
|
+
});
|
|
231
|
+
const created = await createRes.json();
|
|
232
|
+
parentId = created.id;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Check if file exists
|
|
236
|
+
let query = `name='${fileName}' and trashed=false`;
|
|
237
|
+
if (parentId)
|
|
238
|
+
query += ` and '${parentId}' in parents`;
|
|
239
|
+
const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
240
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
241
|
+
});
|
|
242
|
+
const findData = await findRes.json();
|
|
243
|
+
const existingId = findData.files?.[0]?.id;
|
|
244
|
+
if (existingId) {
|
|
245
|
+
// Update existing
|
|
246
|
+
const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
|
|
247
|
+
method: "PATCH",
|
|
248
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
249
|
+
body: content,
|
|
250
|
+
});
|
|
251
|
+
return res.ok;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
// Create new — multipart upload with metadata
|
|
255
|
+
const boundary = "mailx_boundary_" + Date.now();
|
|
256
|
+
const metadata = JSON.stringify({ name: fileName, parents: parentId ? [parentId] : [] });
|
|
257
|
+
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}--`;
|
|
258
|
+
const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
261
|
+
body,
|
|
262
|
+
});
|
|
263
|
+
return res.ok;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export function getCloudProvider(provider) {
|
|
271
|
+
switch (provider) {
|
|
272
|
+
case "onedrive":
|
|
273
|
+
return {
|
|
274
|
+
read: oneDriveRead,
|
|
275
|
+
write: oneDriveWrite,
|
|
276
|
+
exists: oneDriveExists,
|
|
277
|
+
};
|
|
278
|
+
case "gdrive":
|
|
279
|
+
return {
|
|
280
|
+
read: gDriveRead,
|
|
281
|
+
write: gDriveWrite,
|
|
282
|
+
exists: async (p) => (await gDriveRead(p)) !== null,
|
|
283
|
+
};
|
|
284
|
+
case "dropbox":
|
|
285
|
+
// TODO: Dropbox API
|
|
286
|
+
return null;
|
|
287
|
+
case "local":
|
|
288
|
+
return {
|
|
289
|
+
read: async (p) => { try {
|
|
290
|
+
return fs.readFileSync(p, "utf-8");
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return null;
|
|
294
|
+
} },
|
|
295
|
+
write: async (p, c) => { try {
|
|
296
|
+
fs.writeFileSync(p, c);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
} },
|
|
302
|
+
exists: async (p) => fs.existsSync(p),
|
|
303
|
+
};
|
|
304
|
+
default:
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
//# sourceMappingURL=cloud.js.map
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
import type { MailxSettings, AccountConfig } from "@bobfrankston/mailx-types";
|
|
19
19
|
declare const LOCAL_DIR: string;
|
|
20
20
|
declare function getSharedDir(): string;
|
|
21
|
+
/** Read a file via cloud API (when filesystem mount not available) */
|
|
22
|
+
export declare function cloudRead(filename: string): Promise<string | null>;
|
|
23
|
+
/** Write a file via cloud API */
|
|
24
|
+
export declare function cloudWrite(filename: string, content: string): Promise<boolean>;
|
|
25
|
+
/** Whether cloud API fallback is active */
|
|
26
|
+
export declare function isCloudMode(): boolean;
|
|
21
27
|
declare const DEFAULT_PREFERENCES: {
|
|
22
28
|
ui: {
|
|
23
29
|
theme: "system" | "dark" | "light";
|
|
@@ -18,6 +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
22
|
// ── Paths ──
|
|
22
23
|
const LOCAL_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
|
|
23
24
|
const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.jsonc");
|
|
@@ -48,15 +49,100 @@ function readLocalConfig() {
|
|
|
48
49
|
return {};
|
|
49
50
|
return readJsonc(LOCAL_CONFIG_PATH) || {};
|
|
50
51
|
}
|
|
52
|
+
/** Resolve provider config to a filesystem path */
|
|
53
|
+
function resolveProvider(cfg) {
|
|
54
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
55
|
+
const rel = cfg.path; // e.g., "home/.mailx"
|
|
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
|
+
case "gdrive": {
|
|
67
|
+
const candidates = [
|
|
68
|
+
home && path.join(home, "Google Drive", "My Drive", rel),
|
|
69
|
+
home && path.join(home, "Google Drive Streaming", "My Drive", rel),
|
|
70
|
+
];
|
|
71
|
+
// Check drive letters on Windows
|
|
72
|
+
if (process.platform === "win32") {
|
|
73
|
+
for (const letter of ["G", "H", "I", "J", "K"]) {
|
|
74
|
+
candidates.push(path.join(`${letter}:`, "My Drive", rel));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return candidates.filter(Boolean).find(p => fs.existsSync(p));
|
|
78
|
+
}
|
|
79
|
+
case "dropbox": {
|
|
80
|
+
const candidates = [
|
|
81
|
+
home && path.join(home, "Dropbox", rel),
|
|
82
|
+
home && path.join(home, "dropbox", rel),
|
|
83
|
+
].filter(Boolean);
|
|
84
|
+
return candidates.find(p => fs.existsSync(p));
|
|
85
|
+
}
|
|
86
|
+
case "local":
|
|
87
|
+
return resolvePath(rel);
|
|
88
|
+
default:
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Pending cloud config for API fallback (set when mount not found) */
|
|
93
|
+
let pendingCloudConfig = null;
|
|
51
94
|
function getSharedDir() {
|
|
52
95
|
const config = readLocalConfig();
|
|
53
|
-
if (config.sharedDir)
|
|
54
|
-
|
|
96
|
+
if (config.sharedDir) {
|
|
97
|
+
if (typeof config.sharedDir === "string")
|
|
98
|
+
return resolvePath(config.sharedDir);
|
|
99
|
+
// Object format: { provider, path }
|
|
100
|
+
const resolved = resolveProvider(config.sharedDir);
|
|
101
|
+
if (resolved)
|
|
102
|
+
return resolved;
|
|
103
|
+
// Mount not found — save config for API fallback
|
|
104
|
+
pendingCloudConfig = config.sharedDir;
|
|
105
|
+
console.log(` ${config.sharedDir.provider} not mounted — will try API`);
|
|
106
|
+
}
|
|
55
107
|
// Legacy: derive from settingsPath
|
|
56
108
|
if (config.settingsPath)
|
|
57
109
|
return path.dirname(resolvePath(config.settingsPath));
|
|
58
110
|
return LOCAL_DIR;
|
|
59
111
|
}
|
|
112
|
+
/** Read a file via cloud API (when filesystem mount not available) */
|
|
113
|
+
export async function cloudRead(filename) {
|
|
114
|
+
if (!pendingCloudConfig)
|
|
115
|
+
return null;
|
|
116
|
+
const provider = getCloudProvider(pendingCloudConfig.provider);
|
|
117
|
+
if (!provider)
|
|
118
|
+
return null;
|
|
119
|
+
const cloudPath = `${pendingCloudConfig.path}/${filename}`;
|
|
120
|
+
console.log(` [cloud] Reading ${cloudPath} via ${pendingCloudConfig.provider} API...`);
|
|
121
|
+
const content = await provider.read(cloudPath);
|
|
122
|
+
if (content) {
|
|
123
|
+
// Cache locally
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(LOCAL_DIR, filename), content);
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore cache write failure */ }
|
|
129
|
+
}
|
|
130
|
+
return content;
|
|
131
|
+
}
|
|
132
|
+
/** Write a file via cloud API */
|
|
133
|
+
export async function cloudWrite(filename, content) {
|
|
134
|
+
if (!pendingCloudConfig)
|
|
135
|
+
return false;
|
|
136
|
+
const provider = getCloudProvider(pendingCloudConfig.provider);
|
|
137
|
+
if (!provider)
|
|
138
|
+
return false;
|
|
139
|
+
const cloudPath = `${pendingCloudConfig.path}/${filename}`;
|
|
140
|
+
return provider.write(cloudPath, content);
|
|
141
|
+
}
|
|
142
|
+
/** Whether cloud API fallback is active */
|
|
143
|
+
export function isCloudMode() {
|
|
144
|
+
return pendingCloudConfig !== null;
|
|
145
|
+
}
|
|
60
146
|
// ── File helpers ──
|
|
61
147
|
/** Read JSON or JSONC file. If exact path not found, tries .json/.jsonc variant. */
|
|
62
148
|
function readJsonc(filePath) {
|
|
@@ -310,18 +396,32 @@ export { getSharedDir };
|
|
|
310
396
|
/** Auto-detect shared settings on OneDrive or common cloud sync locations */
|
|
311
397
|
function detectSharedDir() {
|
|
312
398
|
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
399
|
+
// Scan common drive letters for Google Drive mount
|
|
400
|
+
const driveLetters = [];
|
|
401
|
+
if (process.platform === "win32") {
|
|
402
|
+
for (const letter of ["G", "H", "I", "J", "K"]) {
|
|
403
|
+
driveLetters.push(path.join(`${letter}:`, "My Drive", "home", ".mailx"));
|
|
404
|
+
driveLetters.push(path.join(`${letter}:`, "My Drive", "mailx"));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
313
407
|
const candidates = [
|
|
314
|
-
// OneDrive
|
|
408
|
+
// OneDrive (Windows env vars)
|
|
315
409
|
process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx"),
|
|
316
410
|
process.env.OneDriveConsumer && path.join(process.env.OneDriveConsumer, "home", ".mailx"),
|
|
411
|
+
// OneDrive (standard paths)
|
|
317
412
|
home && path.join(home, "OneDrive", "home", ".mailx"),
|
|
318
|
-
// Linux/Mac — case variations
|
|
319
413
|
home && path.join(home, "onedrive", "home", ".mailx"),
|
|
320
|
-
|
|
414
|
+
// Google Drive for Desktop — home/.mailx convention (matches OneDrive)
|
|
415
|
+
home && path.join(home, "Google Drive", "My Drive", "home", ".mailx"),
|
|
416
|
+
home && path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx"),
|
|
417
|
+
// Google Drive — also check mailx at root
|
|
418
|
+
home && path.join(home, "Google Drive", "My Drive", "mailx"),
|
|
419
|
+
home && path.join(home, "Google Drive Streaming", "My Drive", "mailx"),
|
|
420
|
+
// Google Drive mount letters (Windows)
|
|
421
|
+
...driveLetters,
|
|
321
422
|
// Dropbox
|
|
322
423
|
home && path.join(home, "Dropbox", ".mailx"),
|
|
323
424
|
home && path.join(home, "dropbox", ".mailx"),
|
|
324
|
-
// Local fallback — just use ~/.mailx itself
|
|
325
425
|
].filter(Boolean);
|
|
326
426
|
for (const dir of candidates) {
|
|
327
427
|
if (fs.existsSync(path.join(dir, "settings.jsonc")) || fs.existsSync(path.join(dir, "accounts.jsonc"))) {
|