@bobfrankston/mailx 1.0.147 → 1.0.151

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.
@@ -3,38 +3,7 @@
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";
7
6
  import { MailxService } from "@bobfrankston/mailx-service";
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
- }
38
7
  export function createApiRouter(db, imapManager) {
39
8
  const svc = new MailxService(db, imapManager);
40
9
  const router = Router();
@@ -140,72 +109,10 @@ export function createApiRouter(db, imapManager) {
140
109
  router.post("/setup", async (req, res) => {
141
110
  try {
142
111
  const { name, email, password } = req.body;
143
- if (!email) {
144
- res.status(400).json({ error: "Email address required" });
145
- return;
146
- }
147
- // Detect provider via domain or MX records (Google Workspace, Microsoft 365 custom domains)
148
- const domain = email.split("@")[1]?.toLowerCase() || "";
149
- const detected = await detectEmailProvider(domain);
150
- if (detected?.cloud) {
151
- await initCloudConfig(detected.cloud);
152
- }
153
- // Check if cloud config found existing accounts (e.g., from GDrive mount or API)
154
- let accounts = loadAccounts();
155
- if (accounts.length === 0) {
156
- accounts = await loadAccountsAsync();
157
- }
158
- if (accounts.length > 0) {
159
- // Existing accounts found on cloud — use them, don't create new
160
- console.log(` Found ${accounts.length} existing account(s) from cloud settings`);
161
- const settings = loadSettings();
162
- for (const acct of settings.accounts) {
163
- if (!acct.enabled)
164
- continue;
165
- try {
166
- await imapManager.addAccount(acct);
167
- console.log(` Account loaded: ${acct.label || acct.name} (${acct.id})`);
168
- }
169
- catch (e) {
170
- console.error(` Account ${acct.id} error: ${e.message}`);
171
- }
172
- }
173
- imapManager.syncAll().catch(() => { });
174
- res.json({ ok: true, message: `Loaded ${accounts.length} existing account(s) from cloud settings.` });
175
- return;
176
- }
177
- // No existing accounts — build new account config
178
- const account = { email, name: name || email.split("@")[0] };
179
- if (password)
180
- account.password = password;
181
- // For custom domains hosted on Google/Microsoft, set correct IMAP/SMTP servers
182
- if (detected && !["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain)) {
183
- account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
184
- account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
185
- }
186
- const id = domain.split(".")[0] || "account";
187
- if (accounts.some((a) => a.email === email)) {
188
- res.json({ ok: false, error: "Account already exists" });
189
- return;
190
- }
191
- account.id = id;
192
- accounts.push(account);
193
- saveAccounts(accounts);
194
- // Reload settings and register the new account in DB + IMAP so it works immediately
195
- const settings = loadSettings();
196
- const normalized = settings.accounts.find(a => a.id === id);
197
- if (normalized) {
198
- try {
199
- await imapManager.addAccount(normalized);
200
- console.log(` Account added: ${normalized.name} (${normalized.id})`);
201
- // Start syncing in background
202
- imapManager.syncAll().catch(() => { });
203
- }
204
- catch (e) {
205
- console.error(` Account setup IMAP error: ${e.message}`);
206
- }
207
- }
208
- res.json({ ok: true, message: "Account added and syncing." });
112
+ const result = await svc.setupAccount(name, email, password);
113
+ if (!result.ok)
114
+ res.status(400);
115
+ res.json(result);
209
116
  }
210
117
  catch (e) {
211
118
  res.status(500).json({ error: e.message });
@@ -214,40 +121,8 @@ export function createApiRouter(db, imapManager) {
214
121
  // ── Repair: restore accounts from DB cache to settings, re-register IMAP ──
215
122
  router.post("/repair-accounts", async (req, res) => {
216
123
  try {
217
- // Get accounts from DB (stale but present)
218
- const dbAccounts = db.getAccountConfigs();
219
- if (dbAccounts.length === 0) {
220
- res.json({ ok: false, error: "No cached accounts in database" });
221
- return;
222
- }
223
- // Rebuild account configs from DB's stored config_json
224
- const restored = [];
225
- for (const a of dbAccounts) {
226
- try {
227
- const cfg = JSON.parse(a.configJson);
228
- restored.push(cfg);
229
- }
230
- catch { /* skip corrupt entries */ }
231
- }
232
- if (restored.length === 0) {
233
- res.json({ ok: false, error: "Could not parse cached account configs" });
234
- return;
235
- }
236
- // Save back to shared dir (and cloud API if active)
237
- saveAccounts(restored);
238
- // Re-register in IMAP manager
239
- for (const acct of restored) {
240
- try {
241
- await imapManager.addAccount(acct);
242
- console.log(` [repair] Re-registered account: ${acct.name} (${acct.id})`);
243
- }
244
- catch (e) {
245
- console.error(` [repair] Failed to register ${acct.id}: ${e.message}`);
246
- }
247
- }
248
- // Start sync
249
- imapManager.syncAll().catch(() => { });
250
- res.json({ ok: true, message: `Restored ${restored.length} account(s) and started sync.` });
124
+ const result = await svc.repairAccounts();
125
+ res.json(result);
251
126
  }
252
127
  catch (e) {
253
128
  res.status(500).json({ error: e.message });
@@ -108,7 +108,7 @@ export class ImapManager extends EventEmitter {
108
108
  connectionBackoff = new Map();
109
109
  /** Per-account connection semaphore — limits concurrent IMAP connections */
110
110
  connectionSemaphore = new Map();
111
- static MAX_CONNECTIONS = 2; // 1 for sync/fetch, 1 for IDLE
111
+ static MAX_CONNECTIONS = 5; // sync, fetch, IDLE, outbox, sync-actions
112
112
  constructor(db) {
113
113
  super();
114
114
  this.db = db;
@@ -1144,7 +1144,7 @@ export class ImapManager extends EventEmitter {
1144
1144
  if (actions.length === 0)
1145
1145
  return;
1146
1146
  const folders = this.db.getFolders(accountId);
1147
- const client = this.createClient(accountId);
1147
+ const client = await this.createClientWithLimit(accountId);
1148
1148
  try {
1149
1149
  for (const action of actions) {
1150
1150
  const folder = folders.find(f => f.id === action.folderId);
@@ -1727,6 +1727,7 @@ export class ImapManager extends EventEmitter {
1727
1727
  this.stopPeriodicSync();
1728
1728
  this.stopOutboxWorker();
1729
1729
  await this.stopWatching();
1730
+ // Disconnect all persistent fetch clients
1730
1731
  for (const [, client] of this.fetchClients) {
1731
1732
  try {
1732
1733
  await client.logout();
@@ -1734,6 +1735,19 @@ export class ImapManager extends EventEmitter {
1734
1735
  catch { /* ignore */ }
1735
1736
  }
1736
1737
  this.fetchClients.clear();
1738
+ // Force-release all semaphore slots to unblock any waiting operations
1739
+ for (const [accountId, sem] of this.connectionSemaphore) {
1740
+ sem.active = 0;
1741
+ for (const waiter of sem.waiting) {
1742
+ try {
1743
+ waiter();
1744
+ }
1745
+ catch { /* */ }
1746
+ }
1747
+ sem.waiting.length = 0;
1748
+ }
1749
+ this.connectionSemaphore.clear();
1750
+ this.activeConnections.clear();
1737
1751
  }
1738
1752
  }
1739
1753
  //# sourceMappingURL=index.js.map
@@ -61,6 +61,16 @@ export declare class MailxService {
61
61
  provider: string;
62
62
  mode: string;
63
63
  };
64
+ setupAccount(name: string, email: string, password?: string): Promise<{
65
+ ok: boolean;
66
+ error?: string;
67
+ message?: string;
68
+ }>;
69
+ repairAccounts(): Promise<{
70
+ ok: boolean;
71
+ error?: string;
72
+ message?: string;
73
+ }>;
64
74
  getAutocompleteSettings(): AutocompleteSettings;
65
75
  saveAutocompleteSettings(settings: AutocompleteSettings): void;
66
76
  autocomplete(req: AutocompleteRequest): Promise<AutocompleteResponse>;
@@ -3,8 +3,32 @@
3
3
  * Pure business logic — no HTTP, no Express.
4
4
  * Both the Express API (mailx-api) and the Android bridge call these functions.
5
5
  */
6
- import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
6
+ import * as dns from "node:dns/promises";
7
+ import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
7
8
  import { simpleParser } from "mailparser";
9
+ // ── Email provider detection (MX-based) ──
10
+ const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
11
+ const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
12
+ async function detectEmailProvider(domain) {
13
+ if (GOOGLE_DOMAINS.includes(domain))
14
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
15
+ if (MS_DOMAINS.includes(domain))
16
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
17
+ try {
18
+ const records = await dns.resolveMx(domain);
19
+ for (const mx of records) {
20
+ const host = mx.exchange.toLowerCase();
21
+ if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
22
+ return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
23
+ }
24
+ if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
25
+ return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
26
+ }
27
+ }
28
+ }
29
+ catch { /* DNS lookup failed */ }
30
+ return null;
31
+ }
8
32
  // ── Sanitize ──
9
33
  export function sanitizeHtml(html) {
10
34
  let hasRemoteContent = false;
@@ -506,6 +530,82 @@ export class MailxService {
506
530
  getStorageInfo() {
507
531
  return getStorageInfo();
508
532
  }
533
+ // ── Setup & Repair ──
534
+ async setupAccount(name, email, password) {
535
+ if (!email)
536
+ return { ok: false, error: "Email address required" };
537
+ const domain = email.split("@")[1]?.toLowerCase() || "";
538
+ const detected = await detectEmailProvider(domain);
539
+ if (detected?.cloud) {
540
+ await initCloudConfig(detected.cloud);
541
+ }
542
+ // Try to load existing accounts from cloud (merge, don't overwrite)
543
+ let accounts = loadAccounts();
544
+ if (accounts.length === 0) {
545
+ accounts = await loadAccountsAsync();
546
+ }
547
+ // Build the new account entry
548
+ const account = { email, name: name || email.split("@")[0] };
549
+ if (password)
550
+ account.password = password;
551
+ if (detected && !["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain)) {
552
+ account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
553
+ account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
554
+ }
555
+ const id = domain.split(".")[0] || "account";
556
+ // Add new account if not already present
557
+ if (!accounts.some((a) => a.email?.toLowerCase() === email.toLowerCase())) {
558
+ account.id = id;
559
+ accounts.push(account);
560
+ }
561
+ if (accounts.length > 0) {
562
+ console.log(` Saving ${accounts.length} account(s) (merged with existing cloud settings)`);
563
+ }
564
+ saveAccounts(accounts);
565
+ // Re-read normalized settings and register ALL accounts
566
+ const settings = loadSettings();
567
+ for (const acct of settings.accounts) {
568
+ if (!acct.enabled)
569
+ continue;
570
+ try {
571
+ await this.imapManager.addAccount(acct);
572
+ console.log(` Account loaded: ${acct.label || acct.name} (${acct.id})`);
573
+ }
574
+ catch (e) {
575
+ console.error(` Account ${acct.id} error: ${e.message}`);
576
+ }
577
+ }
578
+ this.imapManager.syncAll().catch(() => { });
579
+ return { ok: true, message: `${settings.accounts.length} account(s) configured and syncing.` };
580
+ }
581
+ async repairAccounts() {
582
+ const dbAccounts = this.db.getAccountConfigs();
583
+ if (dbAccounts.length === 0) {
584
+ return { ok: false, error: "No cached accounts in database" };
585
+ }
586
+ const restored = [];
587
+ for (const a of dbAccounts) {
588
+ try {
589
+ restored.push(JSON.parse(a.configJson));
590
+ }
591
+ catch { /* skip corrupt */ }
592
+ }
593
+ if (restored.length === 0) {
594
+ return { ok: false, error: "Could not parse cached account configs" };
595
+ }
596
+ saveAccounts(restored);
597
+ for (const acct of restored) {
598
+ try {
599
+ await this.imapManager.addAccount(acct);
600
+ console.log(` [repair] Re-registered account: ${acct.name} (${acct.id})`);
601
+ }
602
+ catch (e) {
603
+ console.error(` [repair] Failed to register ${acct.id}: ${e.message}`);
604
+ }
605
+ }
606
+ this.imapManager.syncAll().catch(() => { });
607
+ return { ok: true, message: `Restored ${restored.length} account(s) and started sync.` };
608
+ }
509
609
  // ── Autocomplete ──
510
610
  getAutocompleteSettings() {
511
611
  return loadAutocomplete();
@@ -4,6 +4,11 @@
4
4
  * Used by msger's bidirectional IPC (Phase 3) and can also
5
5
  * serve as a stdio JSON-RPC interface for testing.
6
6
  */
7
+ import { readFileSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const rootPkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
7
12
  /** Dispatch an incoming mailxapi call to the appropriate MailxService method */
8
13
  export async function dispatch(svc, req) {
9
14
  const { _action, _cbid, ...params } = req;
@@ -94,13 +99,29 @@ async function dispatchAction(svc, action, p) {
94
99
  svc.allowRemoteContent(p.type, p.value);
95
100
  return { ok: true };
96
101
  case "getVersion": {
97
- const settings = svc.getSettings();
98
- const storage = svc.getStorageInfo();
99
- return { version: p._version || "dev", theme: settings.ui?.theme || "system", storage };
102
+ try {
103
+ const settings = svc.getSettings();
104
+ const storage = svc.getStorageInfo();
105
+ return { version: rootPkg.version || "dev", theme: settings.ui?.theme || "system", storage };
106
+ }
107
+ catch (e) {
108
+ console.error(` [jsonrpc] getVersion error: ${e.message}`);
109
+ return { version: rootPkg.version || "dev", theme: "system", storage: {} };
110
+ }
100
111
  }
101
112
  // Autocomplete
102
113
  case "autocomplete":
103
114
  return svc.autocomplete(p);
115
+ case "getAutocompleteSettings":
116
+ return svc.getAutocompleteSettings();
117
+ case "saveAutocompleteSettings":
118
+ svc.saveAutocompleteSettings(p);
119
+ return { ok: true };
120
+ // Setup & Repair
121
+ case "setupAccount":
122
+ return svc.setupAccount(p.name, p.email, p.password);
123
+ case "repairAccounts":
124
+ return svc.repairAccounts();
104
125
  default:
105
126
  throw new Error(`Unknown action: ${action}`);
106
127
  }
@@ -51,28 +51,12 @@ function readLocalConfig() {
51
51
  }
52
52
  /** Resolve provider config to a filesystem path (checks for local Google Drive mount) */
53
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 "google":
58
- case "gdrive": {
59
- const candidates = [
60
- home && path.join(home, "Google Drive", "My Drive", rel),
61
- home && path.join(home, "Google Drive Streaming", "My Drive", rel),
62
- ];
63
- // Check drive letters on Windows
64
- if (process.platform === "win32") {
65
- for (const letter of ["G", "H", "I", "J", "K"]) {
66
- candidates.push(path.join(`${letter}:`, "My Drive", rel));
67
- }
68
- }
69
- return candidates.filter(Boolean).find(p => fs.existsSync(p));
70
- }
71
- case "local":
72
- return resolvePath(rel);
73
- default:
74
- return undefined;
75
- }
54
+ // Cloud providers use API only no filesystem mount scanning
55
+ if (cfg.provider === "gdrive" || cfg.provider === "google")
56
+ return undefined;
57
+ if (cfg.provider === "local")
58
+ return resolvePath(cfg.path);
59
+ return undefined;
76
60
  }
77
61
  /** Pending cloud config for API fallback (set when mount not found) */
78
62
  let pendingCloudConfig = null;
@@ -173,17 +157,25 @@ export function getStorageInfo() {
173
157
  if (config.sharedDir) {
174
158
  const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
175
159
  for (const entry of entries) {
176
- const resolved = resolveSharedEntry(entry);
177
- if (resolved && resolved !== LOCAL_DIR) {
178
- if (typeof entry === "string") {
179
- // Legacy string path — filesystem only, no cloud label
160
+ if (typeof entry === "string") {
161
+ const resolved = resolveSharedEntry(entry);
162
+ if (resolved && resolved !== LOCAL_DIR) {
180
163
  return { provider: "local", mode: "local", cloudPath: resolved };
181
164
  }
182
- const name = (entry.provider === "gdrive" || entry.provider === "google") ? "gdrive" : entry.provider;
165
+ continue;
166
+ }
167
+ // Provider-based entry — check for API mode (folderId) first, then mount
168
+ const name = (entry.provider === "gdrive" || entry.provider === "google") ? "gdrive" : entry.provider;
169
+ if (entry.folderId) {
170
+ // Has folder ID → API mode (don't scan filesystem for mounts)
171
+ return { provider: name, mode: "api", cloudPath: entry.path, cloudError: lastCloudError || undefined };
172
+ }
173
+ const resolved = resolveSharedEntry(entry);
174
+ if (resolved && resolved !== LOCAL_DIR) {
183
175
  return { provider: name, mode: "mount", cloudPath: entry.path };
184
176
  }
185
177
  }
186
- // Not mounted — using API
178
+ // Not mounted and no folderId check pendingCloudConfig from initCloudConfig()
187
179
  if (pendingCloudConfig) {
188
180
  const name = (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "gdrive" : pendingCloudConfig.provider;
189
181
  return { provider: name, mode: "api", cloudPath: pendingCloudConfig.path, cloudError: lastCloudError || undefined };
@@ -266,26 +258,37 @@ function saveFile(filename, data) {
266
258
  }
267
259
  const PROVIDERS = {
268
260
  "gmail.com": {
261
+ label: "Gmail",
269
262
  imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
270
263
  smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
271
264
  },
272
265
  "googlemail.com": {
266
+ label: "Gmail",
273
267
  imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
274
268
  smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
275
269
  },
276
270
  "outlook.com": {
271
+ label: "Outlook",
277
272
  imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
278
273
  smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
279
274
  },
280
275
  "hotmail.com": {
276
+ label: "Hotmail",
281
277
  imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
282
278
  smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
283
279
  },
284
280
  "yahoo.com": {
281
+ label: "Yahoo",
285
282
  imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
286
283
  smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
287
284
  },
285
+ "aol.com": {
286
+ label: "AOL",
287
+ imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
288
+ smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
289
+ },
288
290
  "icloud.com": {
291
+ label: "iCloud",
289
292
  imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
290
293
  smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
291
294
  },
@@ -299,7 +302,7 @@ function normalizeAccount(acct, globalName) {
299
302
  return {
300
303
  id: acct.id || domain.split(".")[0] || "account",
301
304
  name: acct.name || globalName || email.split("@")[0],
302
- label: acct.label,
305
+ label: acct.label || provider?.label,
303
306
  email,
304
307
  imap: {
305
308
  host: acct.imap?.host || provider?.imap.host || `imap.${domain}`,