@bobfrankston/mailx 1.0.166 → 1.0.168

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
@@ -201,7 +201,7 @@ if (importMode) {
201
201
  const wrapper = { accounts: merged };
202
202
  if (data?.name)
203
203
  wrapper.name = data.name;
204
- saveAccounts(merged);
204
+ await saveAccounts(merged);
205
205
  console.log(`Saved ${merged.length} account(s). Run 'mailx' to start.`);
206
206
  process.exit(0);
207
207
  }
@@ -86,6 +86,8 @@ export function reloadCurrentFolder() {
86
86
  }
87
87
  /** Load unified inbox (all accounts) */
88
88
  export async function loadUnifiedInbox(autoSelect = true) {
89
+ if (autoSelect)
90
+ clearViewer();
89
91
  unifiedMode = true;
90
92
  currentPage = 1;
91
93
  totalMessages = 0;
package/client/index.html CHANGED
@@ -46,11 +46,11 @@
46
46
  <span class="tb-icon">↻</span> Sync
47
47
  </button>
48
48
  <div class="tb-menu" id="restart-menu">
49
- <button class="tb-btn" id="btn-restart" title="Restart server and reload page">
49
+ <button class="tb-btn" id="btn-restart" title="Reload">
50
50
  <span class="tb-icon">⚡</span> Restart ▾
51
51
  </button>
52
52
  <div class="tb-menu-dropdown" id="restart-dropdown" hidden>
53
- <button class="tb-menu-item" id="btn-restart-quick" title="Restart the server process">Restart server</button>
53
+ <button class="tb-menu-item" id="btn-restart-quick" title="Reload the page">Reload</button>
54
54
  <button class="tb-menu-item" id="btn-rebuild" title="Wipe local DB and message cache, re-download everything. Accounts and settings are preserved. Safe and fast.">Rebuild local cache</button>
55
55
  <hr class="tb-menu-sep">
56
56
  <span class="tb-menu-hint">CLI: mailx --rebuild for full reset</span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.166",
3
+ "version": "1.0.168",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.1",
25
25
  "@bobfrankston/miscinfo": "^1.0.7",
26
26
  "@bobfrankston/oauthsupport": "^1.0.20",
27
- "@bobfrankston/msger": "^0.1.216",
27
+ "@bobfrankston/msger": "^0.1.218",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -113,7 +113,7 @@ export declare function getSyncPending(): {
113
113
  export declare function allowRemoteContent(params: {
114
114
  type: string;
115
115
  value: string;
116
- }): void;
116
+ }): Promise<void>;
117
117
  export declare function getSettings(): import("@bobfrankston/mailx-types").MailxSettings;
118
118
  export declare function saveSettingsData(data: any): void;
119
119
  export declare function rebuildSearchIndex(): number;
@@ -312,7 +312,7 @@ export async function syncAll() {
312
312
  export function getSyncPending() {
313
313
  return { pending: db.getTotalPendingSyncCount() };
314
314
  }
315
- export function allowRemoteContent(params) {
315
+ export async function allowRemoteContent(params) {
316
316
  const list = loadAllowlist();
317
317
  if (params.type === "sender" && !list.senders.includes(params.value))
318
318
  list.senders.push(params.value);
@@ -324,7 +324,7 @@ export function allowRemoteContent(params) {
324
324
  if (!list.recipients.includes(params.value))
325
325
  list.recipients.push(params.value);
326
326
  }
327
- saveAllowlist(list);
327
+ await saveAllowlist(list);
328
328
  }
329
329
  export function getSettings() {
330
330
  return loadSettings();
@@ -94,7 +94,7 @@ export declare class ImapManager extends EventEmitter {
94
94
  * If message count changed, triggers inbox sync for that account. */
95
95
  private lastInboxCounts;
96
96
  private quickCheckRunning;
97
- /** Check a single account's inbox */
97
+ /** Check a single account's inbox — uses its own connection, never blocked by sync */
98
98
  quickInboxCheckAccount(accountId: string): Promise<void>;
99
99
  /** Check all accounts (used by legacy callers) */
100
100
  quickInboxCheck(): Promise<void>;
@@ -318,12 +318,27 @@ export class ImapManager extends EventEmitter {
318
318
  // For OAuth accounts, provide a tokenProvider using oauthsupport
319
319
  let tokenProvider;
320
320
  if (account.imap.auth === "oauth2" || (!account.imap.password && account.imap.host?.includes("gmail"))) {
321
- const credPath = path.join(getConfigDir(), "google-credentials.json");
321
+ // Find Google OAuth credentials — check ~/.mailx first, then iflow-direct package
322
+ let credPath = path.join(getConfigDir(), "google-credentials.json");
323
+ if (!fs.existsSync(credPath)) {
324
+ try {
325
+ const pkgDir = path.dirname(import.meta.resolve("@bobfrankston/iflow-direct").replace("file:///", "").replace("file://", ""));
326
+ for (const name of ["iflow-credentials.json"]) {
327
+ const p = path.join(pkgDir, name);
328
+ if (fs.existsSync(p)) {
329
+ credPath = p;
330
+ break;
331
+ }
332
+ }
333
+ }
334
+ catch { /* iflow-direct not resolvable */ }
335
+ }
322
336
  const tokenDir = path.join(getConfigDir(), "tokens", account.imap.user.replace(/[@.]/g, "_"));
323
337
  tokenProvider = async () => {
324
338
  const result = await authenticateOAuth(credPath, {
325
339
  scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly",
326
340
  tokenDirectory: tokenDir,
341
+ credentialsKey: "installed",
327
342
  loginHint: account.imap.user,
328
343
  });
329
344
  return result?.access_token || "";
@@ -811,31 +826,39 @@ export class ImapManager extends EventEmitter {
811
826
  * If message count changed, triggers inbox sync for that account. */
812
827
  lastInboxCounts = new Map();
813
828
  quickCheckRunning = new Set(); // per-account guard
814
- /** Check a single account's inbox */
829
+ /** Check a single account's inbox — uses its own connection, never blocked by sync */
815
830
  async quickInboxCheckAccount(accountId) {
816
- if (this.quickCheckRunning.has(accountId) || this.syncing || this.inboxSyncing)
831
+ if (this.quickCheckRunning.has(accountId))
817
832
  return;
818
833
  if (this.reauthenticating.has(accountId))
819
834
  return;
820
835
  this.quickCheckRunning.add(accountId);
836
+ let client = null;
821
837
  try {
822
838
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
823
839
  if (!inbox)
824
840
  return;
825
- await this.withConnection(accountId, async (client) => {
826
- const count = await client.getMessagesCount("INBOX");
827
- const prev = this.lastInboxCounts.get(accountId) ?? count;
828
- this.lastInboxCounts.set(accountId, count);
829
- if (count !== prev) {
830
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
831
- await this.syncFolder(accountId, inbox.id, client);
832
- }
833
- });
841
+ client = this.newClient(accountId);
842
+ const count = await client.getMessagesCount("INBOX");
843
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
844
+ this.lastInboxCounts.set(accountId, count);
845
+ if (count !== prev) {
846
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
847
+ await this.syncFolder(accountId, inbox.id, client);
848
+ }
849
+ await client.logout();
850
+ client = null;
834
851
  }
835
852
  catch {
836
853
  // Lightweight check — silently ignore errors
837
854
  }
838
855
  finally {
856
+ if (client) {
857
+ try {
858
+ await client.logout();
859
+ }
860
+ catch { /* */ }
861
+ }
839
862
  this.quickCheckRunning.delete(accountId);
840
863
  }
841
864
  }
@@ -853,7 +876,8 @@ export class ImapManager extends EventEmitter {
853
876
  // Password (Dovecot etc): every 60s — conservative, 20-connection limit
854
877
  // IDLE gives instant notification when working; STATUS is the fallback.
855
878
  for (const [accountId] of this.configs) {
856
- const interval = this.isOAuthAccount(accountId) ? 15000 : 60000;
879
+ const QUICK_CHECK_INTERVAL = 2500; // STATUS INBOX is one command, very cheap
880
+ const interval = QUICK_CHECK_INTERVAL;
857
881
  const timer = setInterval(() => {
858
882
  this.quickInboxCheckAccount(accountId).catch(() => { });
859
883
  }, interval);
@@ -20,7 +20,7 @@ export declare class MailxService {
20
20
  getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string): any;
21
21
  getMessage(accountId: string, uid: number, allowRemote?: boolean, folderId?: number): Promise<any>;
22
22
  updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
23
- allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): void;
23
+ allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void>;
24
24
  search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number): Promise<any>;
25
25
  rebuildSearchIndex(): number;
26
26
  getSyncPending(): {
@@ -213,7 +213,7 @@ export class MailxService {
213
213
  await this.imapManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
214
214
  }
215
215
  // ── Remote content allow-list ──
216
- allowRemoteContent(type, value) {
216
+ async allowRemoteContent(type, value) {
217
217
  const list = loadAllowlist();
218
218
  if (type === "sender" && !list.senders.includes(value))
219
219
  list.senders.push(value);
@@ -225,7 +225,7 @@ export class MailxService {
225
225
  if (!list.recipients.includes(value))
226
226
  list.recipients.push(value);
227
227
  }
228
- saveAllowlist(list);
228
+ await saveAllowlist(list);
229
229
  console.log(` [allow] Added ${type}: ${value}`);
230
230
  }
231
231
  // ── Search ──
@@ -571,7 +571,7 @@ export class MailxService {
571
571
  account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
572
572
  }
573
573
  account.id = domain.split(".")[0] || "account";
574
- saveAccounts([account]);
574
+ await saveAccounts([account]);
575
575
  // Re-read normalized settings and register
576
576
  const settings = loadSettings();
577
577
  for (const acct of settings.accounts) {
@@ -603,7 +603,7 @@ export class MailxService {
603
603
  if (restored.length === 0) {
604
604
  return { ok: false, error: "Could not parse cached account configs" };
605
605
  }
606
- saveAccounts(restored);
606
+ await saveAccounts(restored);
607
607
  for (const acct of restored) {
608
608
  try {
609
609
  await this.imapManager.addAccount(acct);
@@ -31,7 +31,7 @@ function findGoogleCredentials() {
31
31
  let dir = import.meta.dirname;
32
32
  for (let i = 0; i < 5; i++) {
33
33
  for (const pkg of ["iflow-direct", "iflow"]) {
34
- for (const name of ["iflow-credentials.json", "credentials.json"]) {
34
+ for (const name of ["iflow-credentials.json"]) {
35
35
  const p = path.join(dir, "node_modules", "@bobfrankston", pkg, name);
36
36
  if (fs.existsSync(p))
37
37
  return p;
@@ -65,7 +65,8 @@ export declare function loadAccounts(): AccountConfig[];
65
65
  /** Load accounts with cloud API fallback (async — use when cloud settings may not be mounted) */
66
66
  export declare function loadAccountsAsync(): Promise<AccountConfig[]>;
67
67
  /** Save account configs */
68
- export declare function saveAccounts(accounts: AccountConfig[]): void;
68
+ /** Save accounts — merges with cloud copy by email (multi-client safe) */
69
+ export declare function saveAccounts(accounts: AccountConfig[]): Promise<void>;
69
70
  /** Load preferences (shared + local overrides, with legacy fallback) */
70
71
  export declare function loadPreferences(): typeof DEFAULT_PREFERENCES;
71
72
  /** Save preferences */
@@ -76,12 +77,12 @@ export declare function loadAutocomplete(): AutocompleteSettings;
76
77
  export declare function saveAutocomplete(settings: AutocompleteSettings): void;
77
78
  /** Load remote content allow-list */
78
79
  export declare function loadAllowlist(): typeof DEFAULT_ALLOWLIST;
79
- /** Save allow-list */
80
- export declare function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): void;
80
+ /** Save allow-list — merges with existing cloud copy (multi-client safe) */
81
+ export declare function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): Promise<void>;
81
82
  /** Load settings — unified view combining all files (backward compatible) */
82
83
  export declare function loadSettings(): MailxSettings;
83
84
  /** Save settings — writes to split files */
84
- export declare function saveSettings(settings: MailxSettings): void;
85
+ export declare function saveSettings(settings: MailxSettings): Promise<void>;
85
86
  /** Get the local store base path */
86
87
  export declare function getStorePath(): string;
87
88
  /** Get the local data directory (DB, store, etc.) */
@@ -454,7 +454,26 @@ export async function loadAccountsAsync() {
454
454
  return [];
455
455
  }
456
456
  /** Save account configs */
457
- export function saveAccounts(accounts) {
457
+ /** Save accounts — merges with cloud copy by email (multi-client safe) */
458
+ export async function saveAccounts(accounts) {
459
+ // Merge with cloud: keep all accounts, deduplicate by normalized email
460
+ try {
461
+ const cloudContent = await cloudRead("accounts.jsonc");
462
+ if (cloudContent) {
463
+ const cloud = parseJsonc(cloudContent);
464
+ const cloudAccts = cloud?.accounts || (Array.isArray(cloud) ? cloud : []);
465
+ if (cloudAccts.length > 0) {
466
+ const seen = new Set(accounts.map(a => normalizeEmail(a.email)));
467
+ for (const ca of cloudAccts) {
468
+ if (ca.email && !seen.has(normalizeEmail(ca.email))) {
469
+ accounts.push(ca);
470
+ seen.add(normalizeEmail(ca.email));
471
+ }
472
+ }
473
+ }
474
+ }
475
+ }
476
+ catch { /* cloud read failed — save local version */ }
458
477
  saveFile("accounts.jsonc", { accounts });
459
478
  }
460
479
  /** Load preferences (shared + local overrides, with legacy fallback) */
@@ -496,9 +515,26 @@ export function saveAutocomplete(settings) {
496
515
  export function loadAllowlist() {
497
516
  return loadFile("allowlist.jsonc", DEFAULT_ALLOWLIST);
498
517
  }
499
- /** Save allow-list */
500
- export function saveAllowlist(list) {
501
- saveFile("allowlist.jsonc", list);
518
+ /** Save allow-list — merges with existing cloud copy (multi-client safe) */
519
+ export async function saveAllowlist(list) {
520
+ // Read current cloud version and merge (other clients may have added entries)
521
+ let merged = { ...list };
522
+ try {
523
+ const cloudContent = await cloudRead("allowlist.jsonc");
524
+ if (cloudContent) {
525
+ const cloud = parseJsonc(cloudContent);
526
+ if (cloud) {
527
+ const mergeArrays = (local, remote) => [...new Set([...local, ...remote])];
528
+ merged = {
529
+ senders: mergeArrays(list.senders || [], cloud.senders || []),
530
+ domains: mergeArrays(list.domains || [], cloud.domains || []),
531
+ recipients: mergeArrays(list.recipients || [], cloud.recipients || []),
532
+ };
533
+ }
534
+ }
535
+ }
536
+ catch { /* cloud read failed — save local version */ }
537
+ saveFile("allowlist.jsonc", merged);
502
538
  }
503
539
  // ── Legacy compatibility ──
504
540
  function loadLegacySettings() {
@@ -529,8 +565,8 @@ export function loadSettings() {
529
565
  };
530
566
  }
531
567
  /** Save settings — writes to split files */
532
- export function saveSettings(settings) {
533
- saveAccounts(settings.accounts);
568
+ export async function saveSettings(settings) {
569
+ await saveAccounts(settings.accounts);
534
570
  savePreferences({ ui: settings.ui, sync: settings.sync });
535
571
  }
536
572
  /** Get the local store base path */