@bobfrankston/mailx 1.0.135 → 1.0.138
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/client/app.js +8 -2
- package/client/components/folder-tree.js +3 -2
- package/package.json +2 -2
- package/packages/mailx-api/index.js +41 -7
- package/packages/mailx-imap/index.d.ts +11 -1
- package/packages/mailx-imap/index.js +108 -30
- package/packages/mailx-server/index.js +16 -2
- package/packages/mailx-settings/cloud.d.ts +14 -5
- package/packages/mailx-settings/cloud.js +13 -113
- package/packages/mailx-settings/index.d.ts +4 -3
- package/packages/mailx-settings/index.js +19 -81
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.138",
|
|
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.52",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.7",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.20",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.3",
|
|
@@ -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
|
+
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) {
|
|
@@ -34,6 +34,9 @@ export declare class ImapManager extends EventEmitter {
|
|
|
34
34
|
useNativeClient: boolean;
|
|
35
35
|
/** Accounts hitting connection limits — back off until this time */
|
|
36
36
|
private connectionBackoff;
|
|
37
|
+
/** Per-account connection semaphore — limits concurrent IMAP connections */
|
|
38
|
+
private connectionSemaphore;
|
|
39
|
+
private static MAX_CONNECTIONS;
|
|
37
40
|
constructor(db: MailxDB);
|
|
38
41
|
/** Get OAuth access token for an account (for SMTP auth) */
|
|
39
42
|
getOAuthToken(accountId: string): Promise<string | null>;
|
|
@@ -51,9 +54,16 @@ export declare class ImapManager extends EventEmitter {
|
|
|
51
54
|
createPublicClient(accountId: string): any;
|
|
52
55
|
/** Track active IMAP connections for diagnostics */
|
|
53
56
|
private activeConnections;
|
|
57
|
+
/** Acquire a connection slot. Resolves when a slot is available. */
|
|
58
|
+
private acquireConnection;
|
|
59
|
+
/** Release a connection slot, unblocking the next waiter. */
|
|
60
|
+
private releaseConnection;
|
|
61
|
+
/** Create client with semaphore — acquires slot, wraps logout to release it. */
|
|
62
|
+
createClientWithLimit(accountId: string): Promise<any>;
|
|
54
63
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
55
64
|
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
56
|
-
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
65
|
+
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
66
|
+
* Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
|
|
57
67
|
private createClient;
|
|
58
68
|
/** Track client logout for connection counting (called automatically by wrapped logout) */
|
|
59
69
|
private trackLogout;
|
|
@@ -71,6 +71,26 @@ async function extractPreview(source) {
|
|
|
71
71
|
return { bodyHtml: "", bodyText: "", preview: "", hasAttachments: false };
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
/** Race a promise against a timeout. On timeout, forcibly logout the client to prevent hanging. */
|
|
75
|
+
async function withTimeout(promise, ms, client, label) {
|
|
76
|
+
let timer;
|
|
77
|
+
const timeout = new Promise((_, reject) => {
|
|
78
|
+
timer = setTimeout(() => {
|
|
79
|
+
// Force-close the client to unblock the hanging promise
|
|
80
|
+
try {
|
|
81
|
+
client.logout?.();
|
|
82
|
+
}
|
|
83
|
+
catch { /* ignore */ }
|
|
84
|
+
reject(new Error(`${label} timeout (${ms / 1000}s)`));
|
|
85
|
+
}, ms);
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
return await Promise.race([promise, timeout]);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
74
94
|
export class ImapManager extends EventEmitter {
|
|
75
95
|
configs = new Map();
|
|
76
96
|
watchers = new Map();
|
|
@@ -86,6 +106,9 @@ export class ImapManager extends EventEmitter {
|
|
|
86
106
|
useNativeClient = false;
|
|
87
107
|
/** Accounts hitting connection limits — back off until this time */
|
|
88
108
|
connectionBackoff = new Map();
|
|
109
|
+
/** Per-account connection semaphore — limits concurrent IMAP connections */
|
|
110
|
+
connectionSemaphore = new Map();
|
|
111
|
+
static MAX_CONNECTIONS = 2; // 1 for sync/fetch, 1 for IDLE
|
|
89
112
|
constructor(db) {
|
|
90
113
|
super();
|
|
91
114
|
this.db = db;
|
|
@@ -186,9 +209,68 @@ export class ImapManager extends EventEmitter {
|
|
|
186
209
|
// private legacyFallbacks = new Set<string>();
|
|
187
210
|
/** Track active IMAP connections for diagnostics */
|
|
188
211
|
activeConnections = new Map(); // accountId → count
|
|
212
|
+
/** Acquire a connection slot. Resolves when a slot is available. */
|
|
213
|
+
acquireConnection(accountId) {
|
|
214
|
+
let sem = this.connectionSemaphore.get(accountId);
|
|
215
|
+
if (!sem) {
|
|
216
|
+
sem = { active: 0, waiting: [] };
|
|
217
|
+
this.connectionSemaphore.set(accountId, sem);
|
|
218
|
+
}
|
|
219
|
+
if (sem.active < ImapManager.MAX_CONNECTIONS) {
|
|
220
|
+
sem.active++;
|
|
221
|
+
return Promise.resolve();
|
|
222
|
+
}
|
|
223
|
+
// At limit — queue and wait
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
sem.waiting.push(() => { sem.active++; resolve(); });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Release a connection slot, unblocking the next waiter. */
|
|
229
|
+
releaseConnection(accountId) {
|
|
230
|
+
const sem = this.connectionSemaphore.get(accountId);
|
|
231
|
+
if (!sem)
|
|
232
|
+
return;
|
|
233
|
+
sem.active = Math.max(0, sem.active - 1);
|
|
234
|
+
if (sem.waiting.length > 0 && sem.active < ImapManager.MAX_CONNECTIONS) {
|
|
235
|
+
const next = sem.waiting.shift();
|
|
236
|
+
next();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Create client with semaphore — acquires slot, wraps logout to release it. */
|
|
240
|
+
async createClientWithLimit(accountId) {
|
|
241
|
+
await this.acquireConnection(accountId);
|
|
242
|
+
try {
|
|
243
|
+
const client = this.createClient(accountId);
|
|
244
|
+
// Wrap logout to also release the semaphore slot
|
|
245
|
+
const originalLogout = client.logout;
|
|
246
|
+
let released = false;
|
|
247
|
+
client.logout = async () => {
|
|
248
|
+
await originalLogout.call(client);
|
|
249
|
+
if (!released) {
|
|
250
|
+
released = true;
|
|
251
|
+
this.releaseConnection(accountId);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
// Safety: release slot if client is never logged out (leak protection)
|
|
255
|
+
const leakRelease = setTimeout(() => {
|
|
256
|
+
if (!released) {
|
|
257
|
+
released = true;
|
|
258
|
+
this.releaseConnection(accountId);
|
|
259
|
+
}
|
|
260
|
+
}, 310000); // slightly after the 5min leak timer in createClient
|
|
261
|
+
if (leakRelease.unref)
|
|
262
|
+
leakRelease.unref();
|
|
263
|
+
return client;
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
this.releaseConnection(accountId);
|
|
267
|
+
throw e;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
189
270
|
/** Create a fresh IMAP client for an account (disposable, single-use).
|
|
190
271
|
* Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
|
|
191
|
-
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
272
|
+
* The client's logout() is wrapped to auto-decrement the connection counter.
|
|
273
|
+
* Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
|
|
192
274
|
createClient(accountId) {
|
|
193
275
|
if (this.reauthenticating.has(accountId))
|
|
194
276
|
throw new Error(`Account ${accountId} is re-authenticating`);
|
|
@@ -201,6 +283,10 @@ export class ImapManager extends EventEmitter {
|
|
|
201
283
|
if (!config)
|
|
202
284
|
throw new Error(`No config for account ${accountId}`);
|
|
203
285
|
const count = (this.activeConnections.get(accountId) || 0) + 1;
|
|
286
|
+
// Hard limit: warn if exceeding max, but still allow (callers should use createClientWithLimit)
|
|
287
|
+
if (count > ImapManager.MAX_CONNECTIONS) {
|
|
288
|
+
console.warn(` [conn] ${accountId}: WARNING exceeding limit (${count} > ${ImapManager.MAX_CONNECTIONS})`);
|
|
289
|
+
}
|
|
204
290
|
this.activeConnections.set(accountId, count);
|
|
205
291
|
const clientType = this.useNativeClient ? "native" : "imapflow";
|
|
206
292
|
console.log(` [conn] ${accountId}: +1 ${clientType} (${count} active)`);
|
|
@@ -537,11 +623,8 @@ export class ImapManager extends EventEmitter {
|
|
|
537
623
|
let client = null;
|
|
538
624
|
try {
|
|
539
625
|
const t0 = Date.now();
|
|
540
|
-
client = this.
|
|
541
|
-
const folders = await
|
|
542
|
-
this.syncFolders(accountId, client),
|
|
543
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Folder list timeout (30s)")), 30000))
|
|
544
|
-
]);
|
|
626
|
+
client = await this.createClientWithLimit(accountId);
|
|
627
|
+
const folders = await withTimeout(this.syncFolders(accountId, client), 30000, client, "Folder list");
|
|
545
628
|
console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
|
|
546
629
|
// Legacy fallback removed — was doubling connections.
|
|
547
630
|
// If native client has issues, set useNativeClient=false or use --legacy-imap flag.
|
|
@@ -552,11 +635,8 @@ export class ImapManager extends EventEmitter {
|
|
|
552
635
|
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
553
636
|
if (inbox) {
|
|
554
637
|
try {
|
|
555
|
-
client = this.
|
|
556
|
-
await
|
|
557
|
-
this.syncFolder(accountId, inbox.id, client),
|
|
558
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
|
|
559
|
-
]);
|
|
638
|
+
client = await this.createClientWithLimit(accountId);
|
|
639
|
+
await withTimeout(this.syncFolder(accountId, inbox.id, client), 60000, client, "Inbox sync");
|
|
560
640
|
await client.logout();
|
|
561
641
|
client = null;
|
|
562
642
|
}
|
|
@@ -644,7 +724,7 @@ export class ImapManager extends EventEmitter {
|
|
|
644
724
|
// Reuse one IMAP connection per account for all folders (avoid 87+ TLS handshakes)
|
|
645
725
|
let client = null;
|
|
646
726
|
try {
|
|
647
|
-
client = this.
|
|
727
|
+
client = await this.createClientWithLimit(accountId);
|
|
648
728
|
for (const folder of remaining) {
|
|
649
729
|
// Skip Trash subfolders on first sync — they're large and low priority
|
|
650
730
|
const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
|
|
@@ -656,10 +736,7 @@ export class ImapManager extends EventEmitter {
|
|
|
656
736
|
// Longer timeout for folders we know are large (Trash, first sync)
|
|
657
737
|
const timeout = highestUid === 0 ? 180000 : 60000;
|
|
658
738
|
try {
|
|
659
|
-
await
|
|
660
|
-
this.syncFolder(accountId, folder.id, client),
|
|
661
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s) on ${folder.path}`)), timeout))
|
|
662
|
-
]);
|
|
739
|
+
await withTimeout(this.syncFolder(accountId, folder.id, client), timeout, client, `Sync ${folder.path}`);
|
|
663
740
|
}
|
|
664
741
|
catch (e) {
|
|
665
742
|
if (e.responseText?.includes("doesn't exist")) {
|
|
@@ -673,7 +750,7 @@ export class ImapManager extends EventEmitter {
|
|
|
673
750
|
await client.logout();
|
|
674
751
|
}
|
|
675
752
|
catch { /* */ }
|
|
676
|
-
client = this.
|
|
753
|
+
client = await this.createClientWithLimit(accountId);
|
|
677
754
|
}
|
|
678
755
|
}
|
|
679
756
|
}
|
|
@@ -706,7 +783,7 @@ export class ImapManager extends EventEmitter {
|
|
|
706
783
|
// Try up to 2 times with fresh clients
|
|
707
784
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
708
785
|
try {
|
|
709
|
-
client = this.
|
|
786
|
+
client = await this.createClientWithLimit(accountId);
|
|
710
787
|
await this.syncFolder(accountId, inbox.id, client);
|
|
711
788
|
await client.logout();
|
|
712
789
|
client = null;
|
|
@@ -750,13 +827,17 @@ export class ImapManager extends EventEmitter {
|
|
|
750
827
|
return;
|
|
751
828
|
if (this.reauthenticating.has(accountId))
|
|
752
829
|
return;
|
|
830
|
+
// Skip if at connection limit — don't queue, just skip this cycle
|
|
831
|
+
const sem = this.connectionSemaphore.get(accountId);
|
|
832
|
+
if (sem && sem.active >= ImapManager.MAX_CONNECTIONS)
|
|
833
|
+
return;
|
|
753
834
|
this.quickCheckRunning.add(accountId);
|
|
754
835
|
let client = null;
|
|
755
836
|
try {
|
|
756
837
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
757
838
|
if (!inbox)
|
|
758
839
|
return;
|
|
759
|
-
client = this.
|
|
840
|
+
client = await this.createClientWithLimit(accountId);
|
|
760
841
|
const count = await client.getMessagesCount("INBOX");
|
|
761
842
|
await client.logout();
|
|
762
843
|
client = null;
|
|
@@ -764,7 +845,7 @@ export class ImapManager extends EventEmitter {
|
|
|
764
845
|
this.lastInboxCounts.set(accountId, count);
|
|
765
846
|
if (count !== prev) {
|
|
766
847
|
console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
|
|
767
|
-
client = this.
|
|
848
|
+
client = await this.createClientWithLimit(accountId);
|
|
768
849
|
await this.syncFolder(accountId, inbox.id, client);
|
|
769
850
|
await client.logout();
|
|
770
851
|
client = null;
|
|
@@ -839,7 +920,7 @@ export class ImapManager extends EventEmitter {
|
|
|
839
920
|
if (this.watchers.has(accountId))
|
|
840
921
|
continue;
|
|
841
922
|
try {
|
|
842
|
-
const watchClient = this.
|
|
923
|
+
const watchClient = await this.createClientWithLimit(accountId);
|
|
843
924
|
const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
|
|
844
925
|
console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
|
|
845
926
|
// Sync just INBOX for speed — full sync runs on the configured interval
|
|
@@ -877,10 +958,10 @@ export class ImapManager extends EventEmitter {
|
|
|
877
958
|
return next;
|
|
878
959
|
}
|
|
879
960
|
/** Get or create a persistent client for body fetching */
|
|
880
|
-
getFetchClient(accountId) {
|
|
961
|
+
async getFetchClient(accountId) {
|
|
881
962
|
let client = this.fetchClients.get(accountId);
|
|
882
963
|
if (!client) {
|
|
883
|
-
client = this.
|
|
964
|
+
client = await this.createClientWithLimit(accountId);
|
|
884
965
|
this.fetchClients.set(accountId, client);
|
|
885
966
|
}
|
|
886
967
|
return client;
|
|
@@ -904,12 +985,9 @@ export class ImapManager extends EventEmitter {
|
|
|
904
985
|
}
|
|
905
986
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
906
987
|
try {
|
|
907
|
-
const client = this.getFetchClient(accountId);
|
|
988
|
+
const client = await this.getFetchClient(accountId);
|
|
908
989
|
// 30s timeout — prevents hanging on stale connections
|
|
909
|
-
const msg = await
|
|
910
|
-
client.fetchMessageByUid(folder.path, uid, { source: true }),
|
|
911
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Body fetch timeout (30s)")), 30000))
|
|
912
|
-
]);
|
|
990
|
+
const msg = await withTimeout(client.fetchMessageByUid(folder.path, uid, { source: true }), 30000, client, "Body fetch");
|
|
913
991
|
if (!msg?.source)
|
|
914
992
|
return null;
|
|
915
993
|
const raw = Buffer.from(msg.source, "utf-8");
|
|
@@ -1359,7 +1437,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1359
1437
|
return;
|
|
1360
1438
|
try {
|
|
1361
1439
|
const outboxPath = await this.ensureOutbox(accountId);
|
|
1362
|
-
const client = this.
|
|
1440
|
+
const client = await this.createClientWithLimit(accountId);
|
|
1363
1441
|
try {
|
|
1364
1442
|
for (const file of files) {
|
|
1365
1443
|
const filePath = path.join(localQueue, file);
|
|
@@ -1389,7 +1467,7 @@ export class ImapManager extends EventEmitter {
|
|
|
1389
1467
|
const account = settings.accounts.find(a => a.id === accountId);
|
|
1390
1468
|
if (!account)
|
|
1391
1469
|
return;
|
|
1392
|
-
const client = this.
|
|
1470
|
+
const client = await this.createClientWithLimit(accountId);
|
|
1393
1471
|
try {
|
|
1394
1472
|
// Get all UIDs in Outbox
|
|
1395
1473
|
const uids = await client.getUids(outboxFolder.path);
|
|
@@ -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,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cloud storage
|
|
3
|
-
* Reads/writes settings files on
|
|
4
|
-
* when the cloud drive is not mounted locally.
|
|
2
|
+
* Cloud storage for mailx settings — Google Drive API.
|
|
3
|
+
* Reads/writes settings files on Google Drive when no local mount is available.
|
|
5
4
|
* Falls back to local cache when offline.
|
|
5
|
+
*
|
|
6
|
+
* ── Restoring removed providers ──
|
|
7
|
+
* OneDrive (removed 2026-04-06): Required microsoft-credentials.json in ~/.mailx/,
|
|
8
|
+
* MS_SCOPES = "https://graph.microsoft.com/Files.ReadWrite offline_access",
|
|
9
|
+
* authenticateOAuth with tokenDir ~/.mailx/tokens/microsoft/.
|
|
10
|
+
* Read/write via Graph API: GET/PUT https://graph.microsoft.com/v1.0/me/drive/root:/{path}:/content
|
|
11
|
+
* Azure AD app registration needed: https://portal.azure.com → App registrations → Desktop app
|
|
12
|
+
*
|
|
13
|
+
* Dropbox (removed 2026-04-06): Never implemented — placeholder only.
|
|
14
|
+
* Would need Dropbox OAuth app, token management, and Dropbox API v2 calls.
|
|
6
15
|
*/
|
|
7
|
-
export type CloudProvider = "
|
|
16
|
+
export type CloudProvider = "gdrive" | "google" | "local";
|
|
8
17
|
export interface CloudFile {
|
|
9
18
|
read(filePath: string): Promise<string | null>;
|
|
10
19
|
write(filePath: string, content: string): Promise<boolean>;
|
|
11
20
|
exists(filePath: string): Promise<boolean>;
|
|
12
21
|
}
|
|
13
|
-
export declare function getCloudProvider(provider:
|
|
22
|
+
export declare function getCloudProvider(provider: string): CloudFile | null;
|
|
14
23
|
//# sourceMappingURL=cloud.d.ts.map
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cloud storage
|
|
3
|
-
* Reads/writes settings files on
|
|
4
|
-
* when the cloud drive is not mounted locally.
|
|
2
|
+
* Cloud storage for mailx settings — Google Drive API.
|
|
3
|
+
* Reads/writes settings files on Google Drive when no local mount is available.
|
|
5
4
|
* Falls back to local cache when offline.
|
|
5
|
+
*
|
|
6
|
+
* ── Restoring removed providers ──
|
|
7
|
+
* OneDrive (removed 2026-04-06): Required microsoft-credentials.json in ~/.mailx/,
|
|
8
|
+
* MS_SCOPES = "https://graph.microsoft.com/Files.ReadWrite offline_access",
|
|
9
|
+
* authenticateOAuth with tokenDir ~/.mailx/tokens/microsoft/.
|
|
10
|
+
* Read/write via Graph API: GET/PUT https://graph.microsoft.com/v1.0/me/drive/root:/{path}:/content
|
|
11
|
+
* Azure AD app registration needed: https://portal.azure.com → App registrations → Desktop app
|
|
12
|
+
*
|
|
13
|
+
* Dropbox (removed 2026-04-06): Never implemented — placeholder only.
|
|
14
|
+
* Would need Dropbox OAuth app, token management, and Dropbox API v2 calls.
|
|
6
15
|
*/
|
|
7
16
|
import fs from "node:fs";
|
|
8
17
|
import path from "node:path";
|
|
9
18
|
import { authenticateOAuth } from "@bobfrankston/oauthsupport";
|
|
10
19
|
const SETTINGS_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".mailx");
|
|
11
20
|
// ── 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
21
|
// Google Drive: reuse iflow's OAuth credentials (same Google Cloud project)
|
|
18
22
|
function findGoogleCredentials() {
|
|
19
23
|
// Check mailx local dir first, then iflow package
|
|
@@ -42,23 +46,6 @@ function findGoogleCredentials() {
|
|
|
42
46
|
const GDRIVE_TOKEN_DIR = path.join(SETTINGS_DIR, "tokens", "gdrive");
|
|
43
47
|
const GDRIVE_SCOPES = "https://www.googleapis.com/auth/drive.file";
|
|
44
48
|
// ── 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
49
|
async function getGoogleDriveToken() {
|
|
63
50
|
const creds = findGoogleCredentials();
|
|
64
51
|
if (!creds) {
|
|
@@ -80,86 +67,7 @@ async function getGoogleDriveToken() {
|
|
|
80
67
|
return null;
|
|
81
68
|
}
|
|
82
69
|
}
|
|
83
|
-
// ── OneDrive Graph API ──
|
|
84
|
-
async function oneDriveRead(filePath) {
|
|
85
|
-
const token = await getMicrosoftToken();
|
|
86
|
-
if (!token)
|
|
87
|
-
return null;
|
|
88
|
-
try {
|
|
89
|
-
const encoded = filePath.split("/").map(encodeURIComponent).join("/");
|
|
90
|
-
const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}:/content`, {
|
|
91
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
92
|
-
});
|
|
93
|
-
if (!res.ok)
|
|
94
|
-
return null;
|
|
95
|
-
return await res.text();
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
async function oneDriveWrite(filePath, content) {
|
|
102
|
-
const token = await getMicrosoftToken();
|
|
103
|
-
if (!token)
|
|
104
|
-
return false;
|
|
105
|
-
try {
|
|
106
|
-
const encoded = filePath.split("/").map(encodeURIComponent).join("/");
|
|
107
|
-
const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}:/content`, {
|
|
108
|
-
method: "PUT",
|
|
109
|
-
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
110
|
-
body: content,
|
|
111
|
-
});
|
|
112
|
-
return res.ok;
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
async function oneDriveExists(filePath) {
|
|
119
|
-
const token = await getMicrosoftToken();
|
|
120
|
-
if (!token)
|
|
121
|
-
return false;
|
|
122
|
-
try {
|
|
123
|
-
const encoded = filePath.split("/").map(encodeURIComponent).join("/");
|
|
124
|
-
const res = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${encoded}`, {
|
|
125
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
126
|
-
});
|
|
127
|
-
return res.ok;
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
70
|
// ── Google Drive API ──
|
|
134
|
-
async function gDriveFind(fileName, parentName) {
|
|
135
|
-
const token = await getGoogleDriveToken();
|
|
136
|
-
if (!token)
|
|
137
|
-
return null;
|
|
138
|
-
try {
|
|
139
|
-
let query = `name='${fileName}' and trashed=false`;
|
|
140
|
-
if (parentName) {
|
|
141
|
-
// Find parent folder first
|
|
142
|
-
const parentRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(`name='${parentName}' and mimeType='application/vnd.google-apps.folder' and trashed=false`)}&fields=files(id)`, {
|
|
143
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
144
|
-
});
|
|
145
|
-
if (parentRes.ok) {
|
|
146
|
-
const data = await parentRes.json();
|
|
147
|
-
if (data.files?.[0])
|
|
148
|
-
query += ` and '${data.files[0].id}' in parents`;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
|
|
152
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
153
|
-
});
|
|
154
|
-
if (!res.ok)
|
|
155
|
-
return null;
|
|
156
|
-
const data = await res.json();
|
|
157
|
-
return data.files?.[0]?.id || null;
|
|
158
|
-
}
|
|
159
|
-
catch {
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
71
|
async function gDriveRead(filePath) {
|
|
164
72
|
const token = await getGoogleDriveToken();
|
|
165
73
|
if (!token) {
|
|
@@ -290,12 +198,6 @@ async function gDriveWrite(filePath, content) {
|
|
|
290
198
|
}
|
|
291
199
|
export function getCloudProvider(provider) {
|
|
292
200
|
switch (provider) {
|
|
293
|
-
case "onedrive":
|
|
294
|
-
return {
|
|
295
|
-
read: oneDriveRead,
|
|
296
|
-
write: oneDriveWrite,
|
|
297
|
-
exists: oneDriveExists,
|
|
298
|
-
};
|
|
299
201
|
case "google":
|
|
300
202
|
case "gdrive":
|
|
301
203
|
return {
|
|
@@ -303,9 +205,6 @@ export function getCloudProvider(provider) {
|
|
|
303
205
|
write: gDriveWrite,
|
|
304
206
|
exists: async (p) => (await gDriveRead(p)) !== null,
|
|
305
207
|
};
|
|
306
|
-
case "dropbox":
|
|
307
|
-
// TODO: Dropbox API
|
|
308
|
-
return null;
|
|
309
208
|
case "local":
|
|
310
209
|
return {
|
|
311
210
|
read: async (p) => { try {
|
|
@@ -324,6 +223,7 @@ export function getCloudProvider(provider) {
|
|
|
324
223
|
exists: async (p) => fs.existsSync(p),
|
|
325
224
|
};
|
|
326
225
|
default:
|
|
226
|
+
console.error(` [cloud] Provider "${provider}" not supported — only gdrive is available`);
|
|
327
227
|
return null;
|
|
328
228
|
}
|
|
329
229
|
}
|
|
@@ -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,8 @@ 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
|
-
export declare function initCloudConfig(provider
|
|
93
|
+
/** Initialize config with Google Drive cloud storage */
|
|
94
|
+
export declare function initCloudConfig(provider?: "gdrive", cloudPath?: string): void;
|
|
94
95
|
declare const DEFAULT_SETTINGS: MailxSettings;
|
|
95
96
|
/** Get historyDays for an account: per-account override > system override > shared default */
|
|
96
97
|
export declare function getHistoryDays(accountId?: string): number;
|
|
@@ -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,9 +103,7 @@ 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) */
|
|
@@ -164,7 +146,7 @@ export async function cloudWrite(filename, content) {
|
|
|
164
146
|
export function isCloudMode() {
|
|
165
147
|
return pendingCloudConfig !== null;
|
|
166
148
|
}
|
|
167
|
-
/** Get storage provider info for display
|
|
149
|
+
/** Get storage provider info for display */
|
|
168
150
|
export function getStorageInfo() {
|
|
169
151
|
const config = readLocalConfig();
|
|
170
152
|
if (config.sharedDir) {
|
|
@@ -172,20 +154,18 @@ export function getStorageInfo() {
|
|
|
172
154
|
for (const entry of entries) {
|
|
173
155
|
const resolved = resolveSharedEntry(entry);
|
|
174
156
|
if (resolved && resolved !== LOCAL_DIR) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return { provider: name, mode: "mount" };
|
|
157
|
+
if (typeof entry === "string") {
|
|
158
|
+
// Legacy string path — filesystem only, no cloud label
|
|
159
|
+
return { provider: "local", mode: "local", cloudPath: resolved };
|
|
160
|
+
}
|
|
161
|
+
const name = (entry.provider === "gdrive" || entry.provider === "google") ? "gdrive" : entry.provider;
|
|
162
|
+
return { provider: name, mode: "mount", cloudPath: entry.path };
|
|
181
163
|
}
|
|
182
164
|
}
|
|
183
|
-
// Not mounted
|
|
165
|
+
// Not mounted — using API
|
|
184
166
|
if (pendingCloudConfig) {
|
|
185
|
-
const name = pendingCloudConfig.provider === "
|
|
186
|
-
|
|
187
|
-
pendingCloudConfig.provider === "dropbox" ? "Dropbox" : pendingCloudConfig.provider;
|
|
188
|
-
return { provider: name, mode: "api", cloudError: lastCloudError || undefined };
|
|
167
|
+
const name = (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "gdrive" : pendingCloudConfig.provider;
|
|
168
|
+
return { provider: name, mode: "api", cloudPath: pendingCloudConfig.path, cloudError: lastCloudError || undefined };
|
|
189
169
|
}
|
|
190
170
|
}
|
|
191
171
|
return { provider: "local", mode: "local" };
|
|
@@ -528,58 +508,16 @@ export function getConfigDir() {
|
|
|
528
508
|
}
|
|
529
509
|
/** Get the shared settings directory */
|
|
530
510
|
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
|
-
}
|
|
511
|
+
// detectSharedDir() removed — cloud storage is configured via API (gdrive/onedrive),
|
|
512
|
+
// not auto-detected from filesystem mounts. Setup form triggers initCloudConfig().
|
|
568
513
|
/** Initialize local config if it doesn't exist */
|
|
569
514
|
export function initLocalConfig(sharedDir, storePath) {
|
|
570
515
|
if (fs.existsSync(LOCAL_CONFIG_PATH) && !sharedDir && !storePath)
|
|
571
516
|
return;
|
|
572
517
|
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
|
-
}
|
|
518
|
+
// Use explicit sharedDir or preserve existing — no auto-detection.
|
|
519
|
+
// Cloud storage is configured when user adds an account (initCloudConfig).
|
|
520
|
+
const resolvedSharedDir = sharedDir || existing.sharedDir;
|
|
583
521
|
const config = {
|
|
584
522
|
...existing,
|
|
585
523
|
sharedDir: resolvedSharedDir,
|
|
@@ -588,8 +526,8 @@ export function initLocalConfig(sharedDir, storePath) {
|
|
|
588
526
|
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
589
527
|
atomicWrite(LOCAL_CONFIG_PATH, config);
|
|
590
528
|
}
|
|
591
|
-
/** Initialize config with
|
|
592
|
-
export function initCloudConfig(provider, cloudPath = "home/.mailx") {
|
|
529
|
+
/** Initialize config with Google Drive cloud storage */
|
|
530
|
+
export function initCloudConfig(provider = "gdrive", cloudPath = "home/.mailx") {
|
|
593
531
|
const existing = readLocalConfig();
|
|
594
532
|
if (existing.sharedDir)
|
|
595
533
|
return; // Already configured
|