@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 CHANGED
@@ -904,10 +904,16 @@ fetch("/api/version").then(r => r.json()).then(d => {
904
904
  const el = document.getElementById("app-version");
905
905
  const storage = d.storage || {};
906
906
  const storageLabel = storage.provider && storage.provider !== "local"
907
- ? ` [${storage.provider}${storage.mode === "api" ? " API" : ""}]`
907
+ ? ` [${storage.provider}]`
908
908
  : "";
909
- if (el)
909
+ if (el) {
910
910
  el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
911
+ // Tooltip: show cloud path and access mode on hover
912
+ if (storage.provider && storage.provider !== "local") {
913
+ const modeDesc = storage.mode === "api" ? "API" : "mount";
914
+ el.title = `${storage.cloudPath || storage.provider} (${modeDesc})`;
915
+ }
916
+ }
911
917
  if (d.settingsError) {
912
918
  showAlert(d.settingsError, "settings-error");
913
919
  // Add repair button to the banner
@@ -367,8 +367,9 @@ async function loadFolderTree(container) {
367
367
  </form>
368
368
  <details style="margin-top:2rem;color:var(--color-text-muted)">
369
369
  <summary>Manual setup (advanced)</summary>
370
- <p style="margin-top:0.5rem">Create <code>~/.mailx/settings.jsonc</code> or point to shared settings:</p>
371
- <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": "~/OneDrive/home/.mailx" }</code>
370
+ <p style="margin-top:0.5rem">Create <code>~/.mailx/config.jsonc</code> with a cloud provider:</p>
371
+ <code style="display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem">{ "sharedDir": { "provider": "gdrive", "path": "home/.mailx" } }</code>
372
+ <p style="margin-top:0.5rem;font-size:0.85rem">Settings sync via Google Drive API (auto-configured for Gmail accounts).</p>
372
373
  </details>
373
374
  </div>`;
374
375
  // Wire up the setup form
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.135",
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.51",
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, initLocalConfig, initCloudConfig, loadSettings } from "@bobfrankston/mailx-settings";
8
+ import { loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadSettings } from "@bobfrankston/mailx-settings";
9
+ /** Detect email provider via MX records (Google Workspace, Microsoft 365 custom domains).
10
+ * Cloud storage is always gdrive (only for Google-hosted domains).
11
+ * Microsoft domains get correct IMAP/SMTP but no cloud auto-config. */
12
+ async function detectEmailProvider(domain) {
13
+ // Known domains — no MX lookup needed
14
+ const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
15
+ const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
16
+ if (GOOGLE_DOMAINS.includes(domain))
17
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
18
+ if (MS_DOMAINS.includes(domain))
19
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
20
+ // MX lookup for custom domains (Google Workspace, Microsoft 365)
21
+ try {
22
+ const records = await dns.resolveMx(domain);
23
+ for (const mx of records) {
24
+ const host = mx.exchange.toLowerCase();
25
+ if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
26
+ console.log(` [setup] MX for ${domain} → Google (${host})`);
27
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
28
+ }
29
+ if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
30
+ console.log(` [setup] MX for ${domain} → Microsoft (${host})`);
31
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
32
+ }
33
+ }
34
+ }
35
+ catch { /* DNS lookup failed — not critical */ }
36
+ return null;
37
+ }
8
38
  export function createApiRouter(db, imapManager) {
9
39
  const svc = new MailxService(db, imapManager);
10
40
  const router = Router();
@@ -114,17 +144,21 @@ export function createApiRouter(db, imapManager) {
114
144
  res.status(400).json({ error: "Email address required" });
115
145
  return;
116
146
  }
117
- // Ensure ~/.mailx exists
118
- initLocalConfig();
119
- // Default to gdrive for Gmail users (creates config.jsonc with gdrive provider)
147
+ // Detect provider via domain or MX records (Google Workspace, Microsoft 365 custom domains)
120
148
  const domain = email.split("@")[1]?.toLowerCase() || "";
121
- if (["gmail.com", "googlemail.com"].includes(domain)) {
122
- initCloudConfig("gdrive");
149
+ const detected = await detectEmailProvider(domain);
150
+ if (detected?.cloud) {
151
+ initCloudConfig(detected.cloud);
123
152
  }
124
- // Build account config (normalizeAccount handles provider detection)
153
+ // Build account config
125
154
  const account = { email, name: name || email.split("@")[0] };
126
155
  if (password)
127
156
  account.password = password;
157
+ // For custom domains hosted on Google/Microsoft, set correct IMAP/SMTP servers
158
+ if (detected && !["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain)) {
159
+ account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
160
+ account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
161
+ }
128
162
  // Load existing accounts — try cloud API in case home/.mailx already exists on Drive
129
163
  let accounts = loadAccounts();
130
164
  if (accounts.length === 0) {
@@ -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.createClient(accountId);
541
- const folders = await Promise.race([
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.createClient(accountId);
556
- await Promise.race([
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.createClient(accountId);
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 Promise.race([
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.createClient(accountId);
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.createClient(accountId);
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.createClient(accountId);
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.createClient(accountId);
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.createClient(accountId);
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.createClient(accountId);
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 Promise.race([
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.createClient(accountId);
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.createClient(accountId);
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 set up.");
58
+ console.log(" No accounts configured. Open http://127.0.0.1:9333 to add your email account.");
59
59
  }
60
60
  }
61
61
  const dbDir = getConfigDir();
@@ -100,7 +100,7 @@ app.get("/api/version", (req, res) => {
100
100
  const settingsError = (dbAccounts > 0 && imapAccounts === 0)
101
101
  ? "No accounts loaded from settings — showing stale data. Check accounts.jsonc on your cloud drive."
102
102
  : undefined;
103
- res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage, imapAccounts, settingsError });
103
+ res.json({ version: SERVER_VERSION, theme: settings.ui?.theme || "system", storage, imapAccounts, hasAccounts: imapAccounts > 0 || dbAccounts > 0, settingsError });
104
104
  });
105
105
  app.all("/info", (req, res) => {
106
106
  res.json({ version: SERVER_VERSION, uptime: Math.round(process.uptime()), port: PORT, imap: imapManager.useNativeClient ? "native" : "imapflow" });
@@ -203,10 +203,24 @@ const clients = new Set();
203
203
  function wireWebSocket() {
204
204
  wss.on("connection", (ws) => {
205
205
  clients.add(ws);
206
+ ws.__alive = true;
206
207
  const connected = { type: "connected" };
207
208
  ws.send(JSON.stringify(connected));
209
+ ws.on("pong", () => { ws.__alive = true; });
208
210
  ws.on("close", () => clients.delete(ws));
209
211
  });
212
+ // Heartbeat — detect dead connections, prevent CLOSE_WAIT zombie accumulation
213
+ setInterval(() => {
214
+ for (const ws of clients) {
215
+ if (ws.__alive === false) {
216
+ clients.delete(ws);
217
+ ws.terminate();
218
+ continue;
219
+ }
220
+ ws.__alive = false;
221
+ ws.ping();
222
+ }
223
+ }, 30000);
210
224
  }
211
225
  function broadcast(event) {
212
226
  const data = JSON.stringify(event);
@@ -1,14 +1,23 @@
1
1
  /**
2
- * Cloud storage abstraction for mailx settings.
3
- * Reads/writes settings files on OneDrive (Graph API) or Google Drive (Drive API)
4
- * when the cloud drive is not mounted locally.
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 = "onedrive" | "gdrive" | "google" | "dropbox" | "local";
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: CloudProvider): CloudFile | null;
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 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.
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 (e.g. "OneDrive", "Google Drive", "local") */
27
+ /** Get storage provider info for display */
28
28
  export declare function getStorageInfo(): {
29
29
  provider: string;
30
30
  mode: "mount" | "api" | "local";
31
+ cloudPath?: string;
31
32
  cloudError?: string;
32
33
  };
33
34
  declare const DEFAULT_PREFERENCES: {
@@ -89,8 +90,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 a cloud provider (e.g. gdrive for Gmail users) */
93
- export declare function initCloudConfig(provider: "gdrive" | "onedrive" | "dropbox", cloudPath?: string): void;
93
+ /** Initialize config with Google Drive cloud storage */
94
+ 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: derive from settingsPath
123
- if (config.settingsPath)
124
- return path.dirname(resolvePath(config.settingsPath));
106
+ // Legacy settingsPath no longer used for shared dir — use loadLegacySettings() for reading only.
125
107
  return LOCAL_DIR;
126
108
  }
127
109
  /** Read a file via cloud API (when filesystem mount not available) */
@@ -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 (e.g. "OneDrive", "Google Drive", "local") */
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
- // Mounted cloud drive
176
- const name = typeof entry === "string" ? "cloud" :
177
- entry.provider === "onedrive" ? "OneDrive" :
178
- (entry.provider === "gdrive" || entry.provider === "google") ? "Google Drive" :
179
- entry.provider === "dropbox" ? "Dropbox" : entry.provider;
180
- return { provider: name, mode: "mount" };
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 but using API fallback
165
+ // Not mounted using API
184
166
  if (pendingCloudConfig) {
185
- const name = pendingCloudConfig.provider === "onedrive" ? "OneDrive" :
186
- (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "Google Drive" :
187
- pendingCloudConfig.provider === "dropbox" ? "Dropbox" : pendingCloudConfig.provider;
188
- return { provider: name, mode: "api", cloudError: lastCloudError || undefined };
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
- /** Auto-detect shared settings on OneDrive or common cloud sync locations */
532
- function detectSharedDir() {
533
- const home = process.env.USERPROFILE || process.env.HOME || "";
534
- // Scan common drive letters for Google Drive mount
535
- const driveLetters = [];
536
- if (process.platform === "win32") {
537
- for (const letter of ["G", "H", "I", "J", "K"]) {
538
- driveLetters.push(path.join(`${letter}:`, "My Drive", "home", ".mailx"));
539
- driveLetters.push(path.join(`${letter}:`, "My Drive", "mailx"));
540
- }
541
- }
542
- const candidates = [
543
- // OneDrive (Windows env vars)
544
- process.env.OneDrive && path.join(process.env.OneDrive, "home", ".mailx"),
545
- process.env.OneDriveConsumer && path.join(process.env.OneDriveConsumer, "home", ".mailx"),
546
- // OneDrive (standard paths)
547
- home && path.join(home, "OneDrive", "home", ".mailx"),
548
- home && path.join(home, "onedrive", "home", ".mailx"),
549
- // Google Drive for Desktop — home/.mailx convention (matches OneDrive)
550
- home && path.join(home, "Google Drive", "My Drive", "home", ".mailx"),
551
- home && path.join(home, "Google Drive Streaming", "My Drive", "home", ".mailx"),
552
- // Google Drive — also check mailx at root
553
- home && path.join(home, "Google Drive", "My Drive", "mailx"),
554
- home && path.join(home, "Google Drive Streaming", "My Drive", "mailx"),
555
- // Google Drive mount letters (Windows)
556
- ...driveLetters,
557
- // Dropbox
558
- home && path.join(home, "Dropbox", ".mailx"),
559
- home && path.join(home, "dropbox", ".mailx"),
560
- ].filter(Boolean);
561
- for (const dir of candidates) {
562
- if (fs.existsSync(path.join(dir, "settings.jsonc")) || fs.existsSync(path.join(dir, "accounts.jsonc"))) {
563
- return dir;
564
- }
565
- }
566
- return undefined;
567
- }
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
- // Auto-detect shared settings if not configured
574
- let resolvedSharedDir = sharedDir || existing.sharedDir;
575
- if (!resolvedSharedDir && existing.settingsPath) {
576
- resolvedSharedDir = path.dirname(existing.settingsPath);
577
- }
578
- if (!resolvedSharedDir) {
579
- resolvedSharedDir = detectSharedDir();
580
- if (resolvedSharedDir)
581
- console.log(` Auto-detected shared settings: ${resolvedSharedDir}`);
582
- }
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 a cloud provider (e.g. gdrive for Gmail users) */
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