@bobfrankston/mailx 1.0.167 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.167",
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.217",
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>;
@@ -323,7 +323,7 @@ export class ImapManager extends EventEmitter {
323
323
  if (!fs.existsSync(credPath)) {
324
324
  try {
325
325
  const pkgDir = path.dirname(import.meta.resolve("@bobfrankston/iflow-direct").replace("file:///", "").replace("file://", ""));
326
- for (const name of ["credentials.json", "iflow-credentials.json"]) {
326
+ for (const name of ["iflow-credentials.json"]) {
327
327
  const p = path.join(pkgDir, name);
328
328
  if (fs.existsSync(p)) {
329
329
  credPath = p;
@@ -338,6 +338,7 @@ export class ImapManager extends EventEmitter {
338
338
  const result = await authenticateOAuth(credPath, {
339
339
  scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly",
340
340
  tokenDirectory: tokenDir,
341
+ credentialsKey: "installed",
341
342
  loginHint: account.imap.user,
342
343
  });
343
344
  return result?.access_token || "";
@@ -825,31 +826,39 @@ export class ImapManager extends EventEmitter {
825
826
  * If message count changed, triggers inbox sync for that account. */
826
827
  lastInboxCounts = new Map();
827
828
  quickCheckRunning = new Set(); // per-account guard
828
- /** Check a single account's inbox */
829
+ /** Check a single account's inbox — uses its own connection, never blocked by sync */
829
830
  async quickInboxCheckAccount(accountId) {
830
- if (this.quickCheckRunning.has(accountId) || this.syncing || this.inboxSyncing)
831
+ if (this.quickCheckRunning.has(accountId))
831
832
  return;
832
833
  if (this.reauthenticating.has(accountId))
833
834
  return;
834
835
  this.quickCheckRunning.add(accountId);
836
+ let client = null;
835
837
  try {
836
838
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
837
839
  if (!inbox)
838
840
  return;
839
- await this.withConnection(accountId, async (client) => {
840
- const count = await client.getMessagesCount("INBOX");
841
- const prev = this.lastInboxCounts.get(accountId) ?? count;
842
- this.lastInboxCounts.set(accountId, count);
843
- if (count !== prev) {
844
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
845
- await this.syncFolder(accountId, inbox.id, client);
846
- }
847
- });
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;
848
851
  }
849
852
  catch {
850
853
  // Lightweight check — silently ignore errors
851
854
  }
852
855
  finally {
856
+ if (client) {
857
+ try {
858
+ await client.logout();
859
+ }
860
+ catch { /* */ }
861
+ }
853
862
  this.quickCheckRunning.delete(accountId);
854
863
  }
855
864
  }
@@ -867,7 +876,8 @@ export class ImapManager extends EventEmitter {
867
876
  // Password (Dovecot etc): every 60s — conservative, 20-connection limit
868
877
  // IDLE gives instant notification when working; STATUS is the fallback.
869
878
  for (const [accountId] of this.configs) {
870
- 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;
871
881
  const timer = setInterval(() => {
872
882
  this.quickInboxCheckAccount(accountId).catch(() => { });
873
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 */