@bobfrankston/mailx 1.0.167 → 1.0.169

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
  }
package/client/app.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts } from "./lib/api-client.js";
9
9
  // ── New message badge (favicon + title) ──
10
10
  let baseTitle = "mailx";
11
11
  let lastSeenCount = 0;
@@ -641,8 +641,7 @@ onWsEvent((event) => {
641
641
  btn.disabled = true;
642
642
  btn.textContent = "Authenticating...";
643
643
  try {
644
- const res = await fetch(`/api/reauth/${event.accountId}`, { method: "POST" });
645
- const data = await res.json();
644
+ const data = await reauthenticate(event.accountId);
646
645
  if (data.ok) {
647
646
  hideAlert();
648
647
  const acctEl = document.getElementById("status-accounts");
@@ -668,8 +667,7 @@ onWsEvent((event) => {
668
667
  btn.disabled = true;
669
668
  btn.textContent = "Syncing...";
670
669
  try {
671
- const res = await fetch(`/api/sync/${event.accountId}`, { method: "POST" });
672
- const data = await res.json();
670
+ const data = await syncAccount(event.accountId);
673
671
  if (data.ok) {
674
672
  hideAlert();
675
673
  const acctEl = document.getElementById("status-accounts");
@@ -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;
@@ -6,7 +6,9 @@
6
6
  * All server operations MUST go through these centralized methods.
7
7
  * Never use fetch("/api/...") directly in components.
8
8
  */
9
- const hasIPC = typeof mailxapi !== "undefined" && mailxapi?.isApp;
9
+ // In popup windows (compose), mailxapi isn't injected fall back to opener's bridge
10
+ const _ipc = typeof mailxapi !== "undefined" ? mailxapi : window.opener?.mailxapi;
11
+ const hasIPC = _ipc?.isApp === true;
10
12
  // ── HTTP fallback ──
11
13
  // Abort controller for message-list requests — cancel stale fetches when folder changes
12
14
  let messageListAbort = null;
@@ -44,29 +46,29 @@ async function api(path, options) {
44
46
  // ── API Methods (IPC or HTTP) ──
45
47
  export function getAccounts() {
46
48
  if (hasIPC)
47
- return mailxapi.getAccounts();
49
+ return _ipc.getAccounts();
48
50
  return api("/accounts");
49
51
  }
50
52
  export function getFolders(accountId) {
51
53
  if (hasIPC)
52
- return mailxapi.getFolders(accountId);
54
+ return _ipc.getFolders(accountId);
53
55
  return api(`/folders/${accountId}`);
54
56
  }
55
57
  export function getMessages(accountId, folderId, page = 1, pageSize = 50) {
56
58
  if (hasIPC)
57
- return mailxapi.getMessages(accountId, folderId, page, pageSize);
59
+ return _ipc.getMessages(accountId, folderId, page, pageSize);
58
60
  const signal = newMessageListSignal();
59
61
  return api(`/messages/${accountId}/${folderId}?page=${page}&pageSize=${pageSize}`, { signal });
60
62
  }
61
63
  export function getUnifiedInbox(page = 1, pageSize = 50) {
62
64
  if (hasIPC)
63
- return mailxapi.getUnifiedInbox(page, pageSize);
65
+ return _ipc.getUnifiedInbox(page, pageSize);
64
66
  const signal = newMessageListSignal();
65
67
  return api(`/messages/unified/inbox?page=${page}&pageSize=${pageSize}`, { signal });
66
68
  }
67
69
  export function searchMessages(query, page = 1, pageSize = 50, scope = "all", accountId = "", folderId = 0) {
68
70
  if (hasIPC)
69
- return mailxapi.searchMessages(query, page, pageSize);
71
+ return _ipc.searchMessages(query, page, pageSize);
70
72
  const params = new URLSearchParams({ q: query, page: String(page), pageSize: String(pageSize), scope });
71
73
  if (scope === "current" && accountId) {
72
74
  params.set("accountId", accountId);
@@ -80,7 +82,7 @@ export function searchMessages(query, page = 1, pageSize = 50, scope = "all", ac
80
82
  }
81
83
  export function getMessage(accountId, uid, allowRemote = false, folderId) {
82
84
  if (hasIPC)
83
- return mailxapi.getMessage(accountId, uid, allowRemote, folderId);
85
+ return _ipc.getMessage(accountId, uid, allowRemote, folderId);
84
86
  const params = new URLSearchParams();
85
87
  if (allowRemote)
86
88
  params.set("allowRemote", "true");
@@ -91,7 +93,7 @@ export function getMessage(accountId, uid, allowRemote = false, folderId) {
91
93
  }
92
94
  export function updateFlags(accountId, uid, flags) {
93
95
  if (hasIPC)
94
- return mailxapi.updateFlags(accountId, uid, flags);
96
+ return _ipc.updateFlags(accountId, uid, flags);
95
97
  return api(`/message/${accountId}/${uid}/flags`, {
96
98
  method: "PATCH",
97
99
  body: JSON.stringify({ flags })
@@ -99,22 +101,32 @@ export function updateFlags(accountId, uid, flags) {
99
101
  }
100
102
  export function triggerSync() {
101
103
  if (hasIPC)
102
- return mailxapi.syncAll();
104
+ return _ipc.syncAll();
103
105
  return api("/sync", { method: "POST" });
104
106
  }
107
+ export function syncAccount(accountId) {
108
+ if (hasIPC)
109
+ return _ipc.syncAccount(accountId);
110
+ return api(`/sync/${accountId}`, { method: "POST" });
111
+ }
112
+ export function reauthenticate(accountId) {
113
+ if (hasIPC)
114
+ return _ipc.reauthenticate(accountId);
115
+ return api(`/reauth/${accountId}`, { method: "POST" });
116
+ }
105
117
  export function getSyncPending() {
106
118
  if (hasIPC)
107
- return mailxapi.getSyncPending();
119
+ return _ipc.getSyncPending();
108
120
  return api("/sync/pending");
109
121
  }
110
122
  export function searchContacts(query) {
111
123
  if (hasIPC)
112
- return mailxapi.searchContacts(query);
124
+ return _ipc.searchContacts(query);
113
125
  return api(`/contacts?q=${encodeURIComponent(query)}`);
114
126
  }
115
127
  export function allowRemoteContent(type, value) {
116
128
  if (hasIPC)
117
- return mailxapi.allowRemoteContent(type, value);
129
+ return _ipc.allowRemoteContent(type, value);
118
130
  return api("/settings/allow-remote", {
119
131
  method: "POST",
120
132
  body: JSON.stringify({ type, value })
@@ -122,14 +134,14 @@ export function allowRemoteContent(type, value) {
122
134
  }
123
135
  export function deleteMessage(accountId, uid) {
124
136
  if (hasIPC)
125
- return mailxapi.deleteMessage?.(accountId, uid);
137
+ return _ipc.deleteMessage?.(accountId, uid);
126
138
  return api(`/message/${accountId}/${uid}`, { method: "DELETE" });
127
139
  }
128
140
  export function deleteMessages(accountId, uids) {
129
141
  if (uids.length === 1)
130
142
  return deleteMessage(accountId, uids[0]);
131
143
  if (hasIPC)
132
- return mailxapi.deleteMessages?.(accountId, uids);
144
+ return _ipc.deleteMessages?.(accountId, uids);
133
145
  return api("/messages/delete", {
134
146
  method: "POST", body: JSON.stringify({ accountId, uids })
135
147
  });
@@ -138,7 +150,7 @@ export function moveMessages(accountId, uids, targetFolderId, targetAccountId) {
138
150
  if (uids.length === 1)
139
151
  return moveMessage(accountId, uids[0], targetFolderId, targetAccountId);
140
152
  if (hasIPC)
141
- return mailxapi.moveMessages?.(accountId, uids, targetFolderId, targetAccountId);
153
+ return _ipc.moveMessages?.(accountId, uids, targetFolderId, targetAccountId);
142
154
  const body = { accountId, uids, targetFolderId };
143
155
  if (targetAccountId)
144
156
  body.targetAccountId = targetAccountId;
@@ -148,7 +160,7 @@ export function moveMessages(accountId, uids, targetFolderId, targetAccountId) {
148
160
  }
149
161
  export function undeleteMessage(accountId, uid, folderId) {
150
162
  if (hasIPC)
151
- return mailxapi.undeleteMessage?.(accountId, uid, folderId);
163
+ return _ipc.undeleteMessage?.(accountId, uid, folderId);
152
164
  return api(`/message/${accountId}/${uid}/undelete`, {
153
165
  method: "POST",
154
166
  body: JSON.stringify({ folderId })
@@ -156,7 +168,7 @@ export function undeleteMessage(accountId, uid, folderId) {
156
168
  }
157
169
  export function moveMessage(accountId, uid, targetFolderId, targetAccountId) {
158
170
  if (hasIPC)
159
- return mailxapi.moveMessage?.(accountId, uid, targetFolderId, targetAccountId);
171
+ return _ipc.moveMessage?.(accountId, uid, targetFolderId, targetAccountId);
160
172
  const body = { targetFolderId };
161
173
  if (targetAccountId)
162
174
  body.targetAccountId = targetAccountId;
@@ -167,7 +179,7 @@ export function moveMessage(accountId, uid, targetFolderId, targetAccountId) {
167
179
  }
168
180
  export function restartServer() {
169
181
  if (hasIPC)
170
- return mailxapi.restart?.();
182
+ return _ipc.restart?.();
171
183
  return api("/restart", { method: "POST" }).catch(() => { });
172
184
  }
173
185
  export function rebuildServer() {
@@ -175,12 +187,12 @@ export function rebuildServer() {
175
187
  }
176
188
  export function markFolderRead(accountId, folderId) {
177
189
  if (hasIPC)
178
- return mailxapi.markFolderRead?.(accountId, folderId);
190
+ return _ipc.markFolderRead?.(accountId, folderId);
179
191
  return api(`/folder/${accountId}/${folderId}/mark-read`, { method: "POST" });
180
192
  }
181
193
  export function createFolder(accountId, parentPath, name) {
182
194
  if (hasIPC)
183
- return mailxapi.createFolder?.(accountId, parentPath, name);
195
+ return _ipc.createFolder?.(accountId, parentPath, name);
184
196
  return api(`/folder/${accountId}`, {
185
197
  method: "POST",
186
198
  body: JSON.stringify({ parentPath, name })
@@ -188,7 +200,7 @@ export function createFolder(accountId, parentPath, name) {
188
200
  }
189
201
  export function renameFolder(accountId, folderId, newName) {
190
202
  if (hasIPC)
191
- return mailxapi.renameFolder?.(accountId, folderId, newName);
203
+ return _ipc.renameFolder?.(accountId, folderId, newName);
192
204
  return api(`/folder/${accountId}/${folderId}/rename`, {
193
205
  method: "POST",
194
206
  body: JSON.stringify({ newName })
@@ -196,22 +208,22 @@ export function renameFolder(accountId, folderId, newName) {
196
208
  }
197
209
  export function deleteFolder(accountId, folderId) {
198
210
  if (hasIPC)
199
- return mailxapi.deleteFolder?.(accountId, folderId);
211
+ return _ipc.deleteFolder?.(accountId, folderId);
200
212
  return api(`/folder/${accountId}/${folderId}`, { method: "DELETE" });
201
213
  }
202
214
  export function emptyFolder(accountId, folderId) {
203
215
  if (hasIPC)
204
- return mailxapi.emptyFolder?.(accountId, folderId);
216
+ return _ipc.emptyFolder?.(accountId, folderId);
205
217
  return api(`/folder/${accountId}/${folderId}/empty`, { method: "POST" });
206
218
  }
207
219
  export function sendMessage(body) {
208
220
  if (hasIPC)
209
- return mailxapi.sendMessage?.(body);
221
+ return _ipc.sendMessage?.(body);
210
222
  return api("/send", { method: "POST", body: JSON.stringify(body) });
211
223
  }
212
224
  export function saveDraft(body) {
213
225
  if (hasIPC)
214
- return mailxapi.saveDraft?.(body);
226
+ return _ipc.saveDraft?.(body);
215
227
  return api("/draft", { method: "POST", body: JSON.stringify(body) });
216
228
  }
217
229
  const eventHandlers = [];
@@ -221,7 +233,7 @@ export function onEvent(handler) {
221
233
  export function connectEvents() {
222
234
  if (hasIPC) {
223
235
  // IPC events come via mailxapi.onEvent
224
- mailxapi.onEvent((event) => {
236
+ _ipc.onEvent((event) => {
225
237
  for (const h of eventHandlers)
226
238
  h(event);
227
239
  });
@@ -246,47 +258,47 @@ export function connectEvents() {
246
258
  // ── Autocomplete ──
247
259
  export function autocomplete(body, signal) {
248
260
  if (hasIPC)
249
- return mailxapi.autocomplete?.(body);
261
+ return _ipc.autocomplete?.(body);
250
262
  return api("/autocomplete", { method: "POST", body: JSON.stringify(body), signal });
251
263
  }
252
264
  export function getAutocompleteSettings() {
253
265
  if (hasIPC)
254
- return mailxapi.getAutocompleteSettings?.();
266
+ return _ipc.getAutocompleteSettings?.();
255
267
  return api("/autocomplete/settings");
256
268
  }
257
269
  export function saveAutocompleteSettings(settings) {
258
270
  if (hasIPC)
259
- return mailxapi.saveAutocompleteSettings?.(settings);
271
+ return _ipc.saveAutocompleteSettings?.(settings);
260
272
  return api("/autocomplete/settings", { method: "POST", body: JSON.stringify(settings) });
261
273
  }
262
274
  export function getVersion() {
263
275
  if (hasIPC)
264
- return mailxapi.getVersion();
276
+ return _ipc.getVersion();
265
277
  return api("/version");
266
278
  }
267
279
  export function getSettings() {
268
280
  if (hasIPC)
269
- return mailxapi.getSettings();
281
+ return _ipc.getSettings();
270
282
  return api("/settings");
271
283
  }
272
284
  export function saveSettings(settings) {
273
285
  if (hasIPC)
274
- return mailxapi.saveSettingsData?.(settings);
286
+ return _ipc.saveSettingsData?.(settings);
275
287
  return api("/settings", { method: "PUT", body: JSON.stringify(settings) });
276
288
  }
277
289
  export function repairAccounts() {
278
290
  if (hasIPC)
279
- return mailxapi.repairAccounts?.();
291
+ return _ipc.repairAccounts?.();
280
292
  return api("/repair-accounts", { method: "POST" });
281
293
  }
282
294
  export function deleteDraft(accountId, draftUid) {
283
295
  if (hasIPC)
284
- return mailxapi.deleteDraft?.(accountId, draftUid);
296
+ return _ipc.deleteDraft?.(accountId, draftUid);
285
297
  return api("/draft", { method: "DELETE", body: JSON.stringify({ accountId, draftUid }) });
286
298
  }
287
299
  export function setupAccount(name, email, password) {
288
300
  if (hasIPC)
289
- return mailxapi.setupAccount?.(name, email, password);
301
+ return _ipc.setupAccount?.(name, email, password);
290
302
  return api("/setup", { method: "POST", body: JSON.stringify({ name, email, password }) });
291
303
  }
292
304
  // Legacy exports for backward compatibility
@@ -103,7 +103,9 @@
103
103
 
104
104
  // Sync
105
105
  syncAll: function() { return callNode("syncAll"); },
106
+ syncAccount: function(accountId) { return callNode("syncAccount", { accountId: accountId }); },
106
107
  getSyncPending: function() { return callNode("getSyncPending"); },
108
+ reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
107
109
 
108
110
  // Bulk operations
109
111
  deleteMessages: function(accountId, uids) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.167",
3
+ "version": "1.0.169",
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,11 +20,11 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow-direct": "^0.1.2",
23
+ "@bobfrankston/iflow-direct": "^0.1.3",
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.219",
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);
@@ -82,8 +82,13 @@ async function dispatchAction(svc, action, p) {
82
82
  case "syncAll":
83
83
  await svc.syncAll();
84
84
  return { ok: true };
85
+ case "syncAccount":
86
+ await svc.syncAccount(p.accountId);
87
+ return { ok: true };
85
88
  case "getSyncPending":
86
89
  return svc.getSyncPending();
90
+ case "reauthenticate":
91
+ return { ok: await svc.reauthenticate(p.accountId) };
87
92
  // Search & contacts
88
93
  case "searchMessages":
89
94
  return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
@@ -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 */