@bobfrankston/mailx 1.0.151 → 1.0.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/mailx.js CHANGED
@@ -72,9 +72,9 @@ if (hasFlag("kill")) {
72
72
  }
73
73
  }
74
74
  catch { /* no process on port */ }
75
- // Kill any node.exe running mailx-server (uses tasklist + powershell instead of wmic)
75
+ // Kill any node.exe running mailx (server or IPC service)
76
76
  try {
77
- const ps = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match 'mailx-server|mailx\\\\packages' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8" }).trim();
77
+ const ps = execSync(`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match 'mailx-server|mailx\\\\packages|mailx\\\\bin' } | Select-Object -ExpandProperty ProcessId"`, { encoding: "utf-8" }).trim();
78
78
  for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
79
79
  try {
80
80
  execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
@@ -368,27 +368,20 @@ async function runSetup() {
368
368
  console.log("\nmailx — first-time setup\n");
369
369
  const home = process.env.USERPROFILE || process.env.HOME || "";
370
370
  const mailxDir = path.join(home, ".mailx");
371
- console.log("Gmail is recommended as the first account (provides contacts, calendar, and cloud settings sync).\n");
372
- const account = await promptForAccount();
373
- if (!account) {
374
- console.log(`\nNo account added. Open http://127.0.0.1:${PORT} in a browser to set up via the UI.`);
371
+ // Ask for email first if it's Gmail, check Drive for existing accounts before prompting further
372
+ console.log("Enter your email address to get started (Gmail recommended for cloud sync).\n");
373
+ const email = await prompt("Email address: ");
374
+ if (!email || !email.includes("@")) {
375
+ console.log(`\nNo account added. The UI will show a setup form.`);
375
376
  fs.mkdirSync(mailxDir, { recursive: true });
376
377
  return false;
377
378
  }
378
- const name = await prompt(`Your name (for From: header) [${account.email.split("@")[0]}]: `) || account.email.split("@")[0];
379
- const settings = {
380
- name,
381
- accounts: [account],
382
- ui: { theme: "system" },
383
- sync: { intervalMinutes: 5, historyDays: 0 },
384
- };
385
- const domain = account.email.split("@")[1]?.toLowerCase() || "";
386
- // Detect if this is a Google-hosted domain (for GDrive cloud storage)
379
+ const domain = email.split("@")[1]?.toLowerCase() || "";
387
380
  let isGoogle = ["gmail.com", "googlemail.com"].includes(domain);
388
381
  if (!isGoogle) {
389
382
  try {
390
- const dns = await import("node:dns/promises");
391
- const records = await dns.resolveMx(domain);
383
+ const dnsmod = await import("node:dns/promises");
384
+ const records = await dnsmod.resolveMx(domain);
392
385
  isGoogle = records.some(mx => {
393
386
  const host = mx.exchange.toLowerCase();
394
387
  return host.endsWith(".google.com") || host.endsWith(".googlemail.com");
@@ -398,67 +391,86 @@ async function runSetup() {
398
391
  }
399
392
  catch { /* DNS lookup failed */ }
400
393
  }
401
- fs.mkdirSync(mailxDir, { recursive: true });
394
+ // For Google-hosted accounts, check Drive for existing settings first
402
395
  if (isGoogle) {
403
- // Save to Google Drive via API — merge with existing settings if present
404
- console.log("\nSaving settings to Google Drive via API...");
396
+ fs.mkdirSync(mailxDir, { recursive: true });
397
+ console.log("\nChecking Google Drive for existing mailx settings...");
405
398
  try {
406
399
  const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
407
400
  const folderId = await gDriveFindOrCreateFolder();
408
401
  if (folderId) {
402
+ console.log(` Drive folder: My Drive/mailx/ (${folderId})`);
409
403
  const gdrive = getCloudProvider("gdrive", folderId);
410
404
  if (gdrive) {
411
- // Read existing accounts from Drive to merge (don't overwrite other accounts)
412
- let accountsList = [account];
413
- // Check accounts.jsonc first, then legacy settings.jsonc
414
- const existingAccts = await gdrive.read("accounts.jsonc");
415
- const existingSettings = existingAccts ? null : await gdrive.read("settings.jsonc");
416
- const existingContent = existingAccts || existingSettings;
417
- if (existingContent) {
418
- try {
419
- const { parse: parseJsonc } = await import("jsonc-parser");
420
- const prev = parseJsonc(existingContent);
421
- const prevAccounts = prev?.accounts || (Array.isArray(prev) ? prev : []);
422
- if (prevAccounts.length > 0) {
423
- accountsList = [...prevAccounts];
424
- const newEmail = account.email.toLowerCase();
425
- if (!accountsList.some((a) => a.email?.toLowerCase() === newEmail)) {
426
- accountsList.push(account);
427
- }
428
- console.log(` Found ${prevAccounts.length} existing account(s) on Drive — merging`);
429
- }
405
+ // Read accounts.jsonc (canonical) ignore legacy settings.jsonc
406
+ const existing = await gdrive.read("accounts.jsonc");
407
+ if (existing) {
408
+ const { parse: parseJsonc } = await import("jsonc-parser");
409
+ const data = parseJsonc(existing);
410
+ const accts = data?.accounts || (Array.isArray(data) ? data : []);
411
+ if (accts.length > 0) {
412
+ console.log(`\nFound ${accts.length} existing account(s) on Google Drive (My Drive/mailx/accounts.jsonc):`);
413
+ for (const a of accts)
414
+ console.log(` • ${a.label || a.name || a.email}`);
415
+ // Save config pointing to Drive no prompts needed
416
+ const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
417
+ fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
418
+ console.log("Local config created. Starting mailx...\n");
419
+ return true;
430
420
  }
431
- catch { /* parse failed, overwrite */ }
432
421
  }
433
- const accountsData = { name, accounts: accountsList };
434
- const content = JSON.stringify(accountsData, null, 2);
435
- const ok = await gdrive.write("accounts.jsonc", content);
422
+ }
423
+ // No existing accounts save Drive config for later
424
+ const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
425
+ fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
426
+ }
427
+ }
428
+ catch (e) {
429
+ console.log(` Drive check failed: ${e.message} — continuing with manual setup`);
430
+ }
431
+ }
432
+ // No existing accounts found — build a new account
433
+ const account = { email };
434
+ const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
435
+ if (!isOAuth) {
436
+ account.password = await prompt("Password (app password for Yahoo/AOL/iCloud): ");
437
+ }
438
+ const name = await prompt(`Your name (for From: header) [${email.split("@")[0]}]: `) || email.split("@")[0];
439
+ fs.mkdirSync(mailxDir, { recursive: true });
440
+ if (isGoogle) {
441
+ // Save to Google Drive via API
442
+ console.log("\nSaving account to Google Drive...");
443
+ try {
444
+ const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
445
+ const folderId = await gDriveFindOrCreateFolder();
446
+ if (folderId) {
447
+ const gdrive = getCloudProvider("gdrive", folderId);
448
+ if (gdrive) {
449
+ const accountsData = { name, accounts: [account] };
450
+ const ok = await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
436
451
  if (ok) {
437
- console.log("Settings saved to Google Drive (mailx folder)");
438
- const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
439
- fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
440
- console.log("Local config created with Drive folder ID.");
452
+ console.log("Account saved to Google Drive.");
453
+ // config.jsonc may already exist from the Drive check above
454
+ if (!fs.existsSync(path.join(mailxDir, "config.jsonc"))) {
455
+ const config = { sharedDir: { provider: "gdrive", path: "mailx", folderId } };
456
+ fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
457
+ }
441
458
  }
442
459
  else {
443
- console.log("Google Drive write failed — saving locally instead.");
444
- fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
460
+ console.log("Drive write failed — saving locally.");
461
+ fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
445
462
  }
446
463
  }
447
464
  }
448
- else {
449
- console.log("Google Drive folder setup failed — saving locally.");
450
- fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
451
- }
452
465
  }
453
466
  catch (e) {
454
- console.log(`Google Drive error: ${e.message} — saving locally.`);
455
- fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
467
+ console.log(`Drive error: ${e.message} — saving locally.`);
468
+ fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
456
469
  }
457
470
  }
458
471
  else {
459
- // Non-Google account — save locally
460
- console.log(`\nSaving settings to ${mailxDir}...`);
461
- fs.writeFileSync(path.join(mailxDir, "settings.jsonc"), JSON.stringify(settings, null, 2));
472
+ // Non-Google — save locally
473
+ fs.writeFileSync(path.join(mailxDir, "accounts.jsonc"), JSON.stringify({ name, accounts: [account] }, null, 2));
462
474
  }
463
475
  console.log("Setup complete. Starting mailx...\n");
464
476
  return true;
package/client/app.js CHANGED
@@ -898,11 +898,19 @@ optAutocomplete?.addEventListener("change", () => {
898
898
  }).catch(() => { });
899
899
  });
900
900
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
901
- // Set baseline version text immediately (async getVersion updates it with storage info)
902
- const versionEl = document.getElementById("app-version");
903
- if (versionEl)
904
- versionEl.textContent = "mailx";
905
- const versionPromise = getVersion();
901
+ // Retry getVersion IPC may not be ready on first call
902
+ async function getVersionWithRetry() {
903
+ for (let i = 0; i < 5; i++) {
904
+ try {
905
+ return await getVersion();
906
+ }
907
+ catch {
908
+ await new Promise(r => setTimeout(r, 1000));
909
+ }
910
+ }
911
+ return { version: "?", storage: {} };
912
+ }
913
+ const versionPromise = getVersionWithRetry();
906
914
  versionPromise.then((d) => {
907
915
  const el = document.getElementById("app-version");
908
916
  const storage = d.storage || {};
@@ -911,10 +919,10 @@ versionPromise.then((d) => {
911
919
  : "";
912
920
  if (el) {
913
921
  el.textContent = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
914
- // Tooltip: show cloud path and access mode on hover
922
+ // Tooltip: show Drive path on hover
915
923
  if (storage.provider && storage.provider !== "local") {
916
- const modeDesc = storage.mode === "api" ? "API" : "mount";
917
- el.title = `${storage.cloudPath || storage.provider} (${modeDesc})`;
924
+ const drivePath = storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider;
925
+ el.title = drivePath;
918
926
  }
919
927
  }
920
928
  if (d.settingsError) {
@@ -330,7 +330,14 @@ async function loadFolderTree(container) {
330
330
  container.innerHTML = `<div class="folder-loading">Loading accounts...</div>`;
331
331
  }
332
332
  try {
333
- const accounts = await getAccounts();
333
+ let accounts = await getAccounts();
334
+ // Accounts may still be registering (OAuth in progress) — retry a few times
335
+ if (accounts.length === 0) {
336
+ for (let retry = 0; retry < 5 && accounts.length === 0; retry++) {
337
+ await new Promise(r => setTimeout(r, 2000));
338
+ accounts = await getAccounts();
339
+ }
340
+ }
334
341
  if (accounts.length === 0) {
335
342
  container.innerHTML = `<div class="folder-loading">No accounts</div>`;
336
343
  // Hide the message list and show setup in the viewer pane (full width)
@@ -396,28 +403,43 @@ async function loadFolderTree(container) {
396
403
  helpEl.textContent = help || "";
397
404
  }
398
405
  });
399
- form?.addEventListener("submit", async (e) => {
400
- e.preventDefault();
401
- const name = document.getElementById("setup-name").value.trim();
406
+ // When a valid email is entered, try setup immediately — if cloud has
407
+ // existing accounts, loads them without needing name/password
408
+ let setupTriggered = false;
409
+ async function trySetup() {
402
410
  const email = emailInput.value.trim();
403
- const password = document.getElementById("setup-password").value;
404
- if (!email)
411
+ if (!email || !email.includes("@") || setupTriggered)
405
412
  return;
406
- statusEl.textContent = "Setting up account...";
413
+ const name = document.getElementById("setup-name").value.trim();
414
+ const password = document.getElementById("setup-password").value;
415
+ setupTriggered = true;
416
+ statusEl.textContent = "Checking for existing accounts...";
407
417
  try {
408
418
  const data = await setupAccount(name, email, password);
409
419
  if (data.ok) {
410
- statusEl.textContent = "Account added! Syncing...";
411
- // Wait for sync to populate folders before reloading
420
+ statusEl.textContent = data.message || "Accounts loaded! Syncing...";
412
421
  setTimeout(() => location.reload(), 5000);
413
422
  }
414
423
  else {
424
+ setupTriggered = false;
415
425
  statusEl.textContent = `Error: ${data.error || "Setup failed"}`;
416
426
  }
417
427
  }
418
428
  catch (err) {
429
+ setupTriggered = false;
419
430
  statusEl.textContent = `Error: ${err.message}`;
420
431
  }
432
+ }
433
+ form?.addEventListener("submit", async (e) => {
434
+ e.preventDefault();
435
+ await trySetup();
436
+ });
437
+ // Auto-trigger for OAuth providers when email looks complete
438
+ emailInput?.addEventListener("change", async () => {
439
+ const domain = emailInput.value.split("@")[1]?.toLowerCase() || "";
440
+ const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
441
+ if (isOAuth)
442
+ await trySetup();
421
443
  });
422
444
  // Show cloud storage status in setup form
423
445
  getVersion().then((d) => {
package/client/index.html CHANGED
@@ -39,7 +39,7 @@
39
39
  <label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
40
40
  </div>
41
41
  </div>
42
- <span id="app-version" class="app-version"></span>
42
+ <span id="app-version" class="app-version">mailx</span>
43
43
  </div>
44
44
  <div class="toolbar-right">
45
45
  <button class="tb-btn" id="btn-sync" title="Sync all folders (F5)">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.151",
3
+ "version": "1.0.153",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -23,7 +23,7 @@
23
23
  "@bobfrankston/iflow": "^1.0.53",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
- "@bobfrankston/msger": "^0.1.201",
26
+ "@bobfrankston/msger": "^0.1.203",
27
27
  "@capacitor/android": "^8.3.0",
28
28
  "@capacitor/cli": "^8.3.0",
29
29
  "@capacitor/core": "^8.3.0",
@@ -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 = 5; // sync, fetch, IDLE, outbox, sync-actions
111
+ static MAX_CONNECTIONS = 2; // 1 for all operations (sequential), 1 for IDLE
112
112
  constructor(db) {
113
113
  super();
114
114
  this.db = db;
@@ -539,12 +539,30 @@ export class MailxService {
539
539
  if (detected?.cloud) {
540
540
  await initCloudConfig(detected.cloud);
541
541
  }
542
- // Try to load existing accounts from cloud (merge, don't overwrite)
542
+ // Check cloud for existing accounts if found, just load them all
543
543
  let accounts = loadAccounts();
544
544
  if (accounts.length === 0) {
545
545
  accounts = await loadAccountsAsync();
546
546
  }
547
- // Build the new account entry
547
+ if (accounts.length > 0) {
548
+ // Existing accounts found on cloud — use them directly
549
+ console.log(` Found ${accounts.length} existing account(s) from cloud settings`);
550
+ const settings = loadSettings();
551
+ for (const acct of settings.accounts) {
552
+ if (!acct.enabled)
553
+ continue;
554
+ try {
555
+ await this.imapManager.addAccount(acct);
556
+ console.log(` Account loaded: ${acct.label || acct.name} (${acct.id})`);
557
+ }
558
+ catch (e) {
559
+ console.error(` Account ${acct.id} error: ${e.message}`);
560
+ }
561
+ }
562
+ this.imapManager.syncAll().catch(() => { });
563
+ return { ok: true, message: `Loaded ${accounts.length} existing account(s) from cloud.` };
564
+ }
565
+ // No existing accounts — create new one
548
566
  const account = { email, name: name || email.split("@")[0] };
549
567
  if (password)
550
568
  account.password = password;
@@ -552,17 +570,9 @@ export class MailxService {
552
570
  account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
553
571
  account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
554
572
  }
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
573
+ account.id = domain.split(".")[0] || "account";
574
+ saveAccounts([account]);
575
+ // Re-read normalized settings and register
566
576
  const settings = loadSettings();
567
577
  for (const acct of settings.accounts) {
568
578
  if (!acct.enabled)
@@ -78,13 +78,11 @@ function getSharedDir() {
78
78
  if (resolved)
79
79
  return resolved;
80
80
  }
81
- // Nothing mounted save last provider entry for API fallback (log once)
81
+ // Set pending cloud config for API access
82
82
  if (!pendingCloudConfig) {
83
83
  const lastProvider = [...entries].reverse().find(e => typeof e !== "string");
84
- if (lastProvider) {
84
+ if (lastProvider)
85
85
  pendingCloudConfig = lastProvider;
86
- console.log(` No cloud drive mounted — will try ${lastProvider.provider} API`);
87
- }
88
86
  }
89
87
  }
90
88
  // Legacy settingsPath no longer used for shared dir — use loadLegacySettings() for reading only.
@@ -388,7 +386,7 @@ export function loadAccounts() {
388
386
  }
389
387
  const raw = accounts.accounts || accounts;
390
388
  const globalName = accounts.name || "";
391
- const result = raw.map((a) => normalizeAccount(a, globalName));
389
+ const result = deduplicateAccounts(raw.map((a) => normalizeAccount(a, globalName)));
392
390
  return applyAccountOverrides(result);
393
391
  }
394
392
  // Legacy: read from settings.jsonc
@@ -397,6 +395,27 @@ export function loadAccounts() {
397
395
  return applyAccountOverrides(legacy.accounts.map((a) => normalizeAccount(a, legacy.name)));
398
396
  return DEFAULT_ACCOUNTS;
399
397
  }
398
+ /** Normalize email for dedup — Gmail ignores dots before @ */
399
+ function normalizeEmail(email) {
400
+ const [local, domain] = email.toLowerCase().split("@");
401
+ if (!domain)
402
+ return email.toLowerCase();
403
+ if (domain === "gmail.com" || domain === "googlemail.com") {
404
+ return local.replace(/\./g, "") + "@gmail.com";
405
+ }
406
+ return `${local}@${domain}`;
407
+ }
408
+ /** Remove duplicate accounts (same email after normalization) */
409
+ function deduplicateAccounts(accounts) {
410
+ const seen = new Set();
411
+ return accounts.filter(a => {
412
+ const key = normalizeEmail(a.email);
413
+ if (seen.has(key))
414
+ return false;
415
+ seen.add(key);
416
+ return true;
417
+ });
418
+ }
400
419
  /** Apply local per-account overrides (enabled, etc.) */
401
420
  function applyAccountOverrides(accounts) {
402
421
  const localConfig = readLocalConfig();
@@ -430,14 +449,7 @@ export async function loadAccountsAsync() {
430
449
  return applyAccountOverrides(raw.map((a) => normalizeAccount(a, globalName)));
431
450
  }
432
451
  }
433
- // Also try legacy settings.jsonc
434
- const legacy = await cloudRead("settings.jsonc");
435
- if (legacy) {
436
- const data = parseJsonc(legacy);
437
- if (data?.accounts) {
438
- return applyAccountOverrides(data.accounts.map((a) => normalizeAccount(a, data.name)));
439
- }
440
- }
452
+ // Legacy settings.jsonc is no longer read — use accounts.jsonc only
441
453
  }
442
454
  return [];
443
455
  }