@bobfrankston/mailx 1.0.50 → 1.0.57

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.
@@ -13,9 +13,8 @@
13
13
  "@bobfrankston/mailx-store": "file:../mailx-store",
14
14
  "@bobfrankston/mailx-imap": "file:../mailx-imap",
15
15
  "@bobfrankston/mailx-settings": "file:../mailx-settings",
16
- "express": "^4.21.0",
17
- "mailparser": "^3.7.2",
18
- "nodemailer": "^7.0.0"
16
+ "@bobfrankston/mailx-service": "file:../mailx-service",
17
+ "express": "^4.21.0"
19
18
  },
20
19
  "devDependencies": {
21
20
  "@types/express": "^5.0.0",
@@ -49,6 +49,10 @@ export declare class ImapManager extends EventEmitter {
49
49
  private _syncAll;
50
50
  /** Sync just INBOX for each account (fast check for new mail) */
51
51
  syncInbox(): Promise<void>;
52
+ /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
53
+ * If message count changed, triggers a full inbox sync. */
54
+ private lastInboxCounts;
55
+ quickInboxCheck(): Promise<void>;
52
56
  /** Start periodic sync */
53
57
  startPeriodicSync(intervalMinutes: number): void;
54
58
  /** Stop periodic sync */
@@ -170,6 +170,7 @@ export class ImapManager extends EventEmitter {
170
170
  // Get the highest UID we already have for this folder
171
171
  const highestUid = this.db.getHighestUid(accountId, folderId);
172
172
  let messages;
173
+ const firstSync = highestUid === 0;
173
174
  if (highestUid > 0) {
174
175
  // Incremental: only fetch messages newer than what we have
175
176
  const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: true });
@@ -184,18 +185,47 @@ export class ImapManager extends EventEmitter {
184
185
  ? new Date(Date.now() - historyDays * 86400000)
185
186
  : new Date(0);
186
187
  messages = await client.fetchMessageByDate(folder.path, startDate, new Date(), { source: true });
188
+ // Sort newest first so most recent messages appear in the UI immediately
189
+ messages.sort((a, b) => {
190
+ const da = a.date instanceof Date ? a.date.getTime() : (typeof a.date === "number" ? a.date : 0);
191
+ const db = b.date instanceof Date ? b.date.getTime() : (typeof b.date === "number" ? b.date : 0);
192
+ return db - da;
193
+ });
187
194
  }
188
195
  if (messages.length > 0)
189
196
  console.log(` ${folder.path}: ${messages.length} new messages`);
190
197
  let newCount = 0;
191
198
  const batchSize = 50;
192
- this.db.beginTransaction();
193
- try {
194
- for (let i = 0; i < messages.length; i++) {
195
- const msg = messages[i];
196
- // Skip if we already have this UID
197
- if (msg.uid <= highestUid) {
198
- // But update flags in case they changed
199
+ for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
200
+ const batchEnd = Math.min(batchStart + batchSize, messages.length);
201
+ this.db.beginTransaction();
202
+ try {
203
+ for (let i = batchStart; i < batchEnd; i++) {
204
+ const msg = messages[i];
205
+ // Skip if we already have this UID
206
+ if (msg.uid <= highestUid) {
207
+ // But update flags in case they changed
208
+ const flags = [];
209
+ if (msg.seen)
210
+ flags.push("\\Seen");
211
+ if (msg.flagged)
212
+ flags.push("\\Flagged");
213
+ if (msg.answered)
214
+ flags.push("\\Answered");
215
+ if (msg.draft)
216
+ flags.push("\\Draft");
217
+ this.db.updateMessageFlags(accountId, msg.uid, flags);
218
+ continue;
219
+ }
220
+ // Store body
221
+ const source = msg.source || "";
222
+ let bodyPath = "";
223
+ if (source) {
224
+ bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
225
+ }
226
+ // Parse for preview and attachment info
227
+ const parsed = await extractPreview(source);
228
+ // Build flags array
199
229
  const flags = [];
200
230
  if (msg.seen)
201
231
  flags.push("\\Seen");
@@ -205,61 +235,49 @@ export class ImapManager extends EventEmitter {
205
235
  flags.push("\\Answered");
206
236
  if (msg.draft)
207
237
  flags.push("\\Draft");
208
- this.db.updateMessageFlags(accountId, msg.uid, flags);
209
- continue;
210
- }
211
- // Store body
212
- const source = msg.source || "";
213
- let bodyPath = "";
214
- if (source) {
215
- bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
238
+ // Store metadata
239
+ this.db.upsertMessage({
240
+ accountId,
241
+ folderId,
242
+ uid: msg.uid,
243
+ messageId: msg.messageId || "",
244
+ inReplyTo: "",
245
+ references: [],
246
+ date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
247
+ subject: msg.subject || "",
248
+ from: toEmailAddress(msg.from?.[0] || {}),
249
+ to: toEmailAddresses(msg.to || []),
250
+ cc: toEmailAddresses(msg.cc || []),
251
+ flags,
252
+ size: msg.size || 0,
253
+ hasAttachments: parsed.hasAttachments,
254
+ preview: parsed.preview,
255
+ bodyPath
256
+ });
257
+ newCount++;
216
258
  }
217
- // Parse for preview and attachment info
218
- const parsed = await extractPreview(source);
219
- // Build flags array
220
- const flags = [];
221
- if (msg.seen)
222
- flags.push("\\Seen");
223
- if (msg.flagged)
224
- flags.push("\\Flagged");
225
- if (msg.answered)
226
- flags.push("\\Answered");
227
- if (msg.draft)
228
- flags.push("\\Draft");
229
- // Store metadata
230
- this.db.upsertMessage({
231
- accountId,
232
- folderId,
233
- uid: msg.uid,
234
- messageId: msg.messageId || "",
235
- inReplyTo: "",
236
- references: [],
237
- date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
238
- subject: msg.subject || "",
239
- from: toEmailAddress(msg.from?.[0] || {}),
240
- to: toEmailAddresses(msg.to || []),
241
- cc: toEmailAddresses(msg.cc || []),
242
- flags,
243
- size: msg.size || 0,
244
- hasAttachments: parsed.hasAttachments,
245
- preview: parsed.preview,
246
- bodyPath
259
+ this.db.commitTransaction();
260
+ }
261
+ catch (e) {
262
+ console.error(` transaction error: ${e.message}`);
263
+ this.db.rollbackTransaction();
264
+ throw e;
265
+ }
266
+ // Emit progress and notify client after each batch
267
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, Math.round((batchEnd / messages.length) * 100));
268
+ // On first sync, emit folderCountsChanged per batch so newest messages appear immediately
269
+ if (firstSync && newCount > 0) {
270
+ const total = newCount;
271
+ const unread = this.db.getMessages({ accountId, folderId, page: 1, pageSize: total })
272
+ .items.filter((m) => !m.flags.includes("\\Seen")).length;
273
+ this.db.updateFolderCounts(folderId, total, unread);
274
+ this.emit("folderCountsChanged", accountId, {
275
+ [folderId]: { total, unread }
247
276
  });
248
- newCount++;
249
- // Emit progress periodically
250
- if (i % batchSize === 0) {
251
- this.emit("syncProgress", accountId, `sync:${folder.path}`, Math.round((i / messages.length) * 100));
252
- }
253
277
  }
254
- this.db.commitTransaction();
255
- if (newCount > 0)
256
- console.log(` stored ${newCount} new messages`);
257
- }
258
- catch (e) {
259
- console.error(` transaction error: ${e.message}`);
260
- this.db.rollbackTransaction();
261
- throw e;
262
278
  }
279
+ if (newCount > 0)
280
+ console.log(` stored ${newCount} new messages`);
263
281
  // Remove messages deleted on the server
264
282
  let deletedCount = 0;
265
283
  try {
@@ -308,10 +326,12 @@ export class ImapManager extends EventEmitter {
308
326
  }
309
327
  }
310
328
  async _syncAll() {
329
+ // Phase 1: Sync folder lists and inboxes for ALL accounts first
330
+ // so every account has content visible quickly
331
+ const accountFolders = new Map();
311
332
  for (const [accountId] of this.configs) {
312
333
  let client = null;
313
334
  try {
314
- // Fresh client for folder list (30s timeout)
315
335
  client = this.createClient(accountId);
316
336
  const folders = await Promise.race([
317
337
  this.syncFolders(accountId, client),
@@ -319,20 +339,14 @@ export class ImapManager extends EventEmitter {
319
339
  ]);
320
340
  await client.logout();
321
341
  client = null;
322
- // INBOX first so it's available fastest
323
- folders.sort((a, b) => {
324
- if (a.specialUse === "inbox")
325
- return -1;
326
- if (b.specialUse === "inbox")
327
- return 1;
328
- return 0;
329
- });
330
- // Fresh client per folder with 60s timeout — IMAP connections can hang
331
- for (const folder of folders) {
342
+ accountFolders.set(accountId, folders);
343
+ // Sync inbox immediately
344
+ const inbox = folders.find(f => f.specialUse === "inbox");
345
+ if (inbox) {
332
346
  try {
333
347
  client = this.createClient(accountId);
334
348
  await Promise.race([
335
- this.syncFolder(accountId, folder.id, client),
349
+ this.syncFolder(accountId, inbox.id, client),
336
350
  new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
337
351
  ]);
338
352
  await client.logout();
@@ -346,16 +360,9 @@ export class ImapManager extends EventEmitter {
346
360
  catch { /* ignore */ }
347
361
  client = null;
348
362
  }
349
- if (e.responseText?.includes("doesn't exist")) {
350
- console.log(` Removing non-existent folder: ${folder.path}`);
351
- this.db.deleteFolder(folder.id);
352
- }
353
- else {
354
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
355
- }
363
+ console.error(` Inbox sync error for ${accountId}: ${e.message}`);
356
364
  }
357
365
  }
358
- this.emit("syncComplete", accountId);
359
366
  }
360
367
  catch (e) {
361
368
  this.emit("syncError", accountId, e.message);
@@ -369,6 +376,40 @@ export class ImapManager extends EventEmitter {
369
376
  catch { /* ignore */ }
370
377
  }
371
378
  }
379
+ // Phase 2: Sync remaining folders for all accounts
380
+ for (const [accountId, folders] of accountFolders) {
381
+ let client = null;
382
+ for (const folder of folders) {
383
+ if (folder.specialUse === "inbox")
384
+ continue; // already synced
385
+ try {
386
+ client = this.createClient(accountId);
387
+ await Promise.race([
388
+ this.syncFolder(accountId, folder.id, client),
389
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
390
+ ]);
391
+ await client.logout();
392
+ client = null;
393
+ }
394
+ catch (e) {
395
+ if (client) {
396
+ try {
397
+ await client.logout();
398
+ }
399
+ catch { /* ignore */ }
400
+ client = null;
401
+ }
402
+ if (e.responseText?.includes("doesn't exist")) {
403
+ console.log(` Removing non-existent folder: ${folder.path}`);
404
+ this.db.deleteFolder(folder.id);
405
+ }
406
+ else {
407
+ console.error(` Skipping folder ${folder.path}: ${e.message}`);
408
+ }
409
+ }
410
+ }
411
+ this.emit("syncComplete", accountId);
412
+ }
372
413
  }
373
414
  /** Sync just INBOX for each account (fast check for new mail) */
374
415
  async syncInbox() {
@@ -419,19 +460,59 @@ export class ImapManager extends EventEmitter {
419
460
  this.inboxSyncing = false;
420
461
  }
421
462
  }
463
+ /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
464
+ * If message count changed, triggers a full inbox sync. */
465
+ lastInboxCounts = new Map();
466
+ async quickInboxCheck() {
467
+ for (const [accountId] of this.configs) {
468
+ let client = null;
469
+ try {
470
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
471
+ if (!inbox)
472
+ continue;
473
+ client = this.createClient(accountId);
474
+ const count = await client.getMessagesCount("INBOX");
475
+ await client.logout();
476
+ client = null;
477
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
478
+ this.lastInboxCounts.set(accountId, count);
479
+ if (count !== prev) {
480
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
481
+ // New mail detected — do a full inbox sync
482
+ client = this.createClient(accountId);
483
+ await this.syncFolder(accountId, inbox.id, client);
484
+ await client.logout();
485
+ client = null;
486
+ }
487
+ }
488
+ catch {
489
+ // Lightweight check — silently ignore errors (full sync will catch up)
490
+ }
491
+ finally {
492
+ if (client)
493
+ try {
494
+ await client.logout();
495
+ }
496
+ catch { /* ignore */ }
497
+ }
498
+ }
499
+ }
422
500
  /** Start periodic sync */
423
501
  startPeriodicSync(intervalMinutes) {
424
502
  this.stopPeriodicSync();
425
- // INBOX poll + sync actions (IDLE handles instant, this catches gaps)
426
- const inboxInterval = setInterval(async () => {
427
- // Process pending local→IMAP sync actions (sends + flags/deletes/moves)
503
+ // Quick inbox check every 3 seconds lightweight STATUS command
504
+ const quickCheck = setInterval(() => {
505
+ this.quickInboxCheck().catch(() => { });
506
+ }, 3000);
507
+ this.syncIntervals.set("quick", quickCheck);
508
+ // Sync actions (sends + flags/deletes/moves) every 30 seconds
509
+ const actionsInterval = setInterval(async () => {
428
510
  for (const [accountId] of this.configs) {
429
511
  this.processSendActions(accountId).catch(() => { });
430
512
  this.processSyncActions(accountId).catch(() => { });
431
513
  }
432
- this.syncInbox().catch(e => console.error(` [inbox] error: ${e.message}`));
433
514
  }, 30000);
434
- this.syncIntervals.set("inbox", inboxInterval);
515
+ this.syncIntervals.set("actions", actionsInterval);
435
516
  // Full sync (all folders + IDLE restart) at configured interval
436
517
  const fullInterval = setInterval(async () => {
437
518
  console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @bobfrankston/mailx-service
3
+ * Pure business logic — no HTTP, no Express.
4
+ * Both the Express API (mailx-api) and the Android bridge call these functions.
5
+ */
6
+ import { MailxDB } from "@bobfrankston/mailx-store";
7
+ import { ImapManager } from "@bobfrankston/mailx-imap";
8
+ import type { Folder } from "@bobfrankston/mailx-types";
9
+ export declare function sanitizeHtml(html: string): {
10
+ html: string;
11
+ hasRemoteContent: boolean;
12
+ };
13
+ export declare class MailxService {
14
+ private db;
15
+ private imapManager;
16
+ constructor(db: MailxDB, imapManager: ImapManager);
17
+ getAccounts(): any[];
18
+ getFolders(accountId: string): Folder[];
19
+ getUnifiedInbox(page?: number, pageSize?: number): any;
20
+ getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string): any;
21
+ getMessage(accountId: string, uid: number, allowRemote?: boolean, folderId?: number): Promise<any>;
22
+ updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
23
+ allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): void;
24
+ search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number): Promise<any>;
25
+ rebuildSearchIndex(): number;
26
+ getSyncPending(): {
27
+ pending: number;
28
+ };
29
+ syncAll(): Promise<void>;
30
+ syncAccount(accountId: string): Promise<void>;
31
+ send(msg: any): Promise<void>;
32
+ deleteMessage(accountId: string, uid: number): Promise<void>;
33
+ moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
34
+ undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
35
+ deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
36
+ createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
37
+ renameFolder(accountId: string, folderId: number, newName: string): Promise<void>;
38
+ deleteFolder(accountId: string, folderId: number): Promise<void>;
39
+ markFolderRead(folderId: number): void;
40
+ emptyFolder(accountId: string, folderId: number): Promise<void>;
41
+ getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{
42
+ content: Buffer;
43
+ contentType: string;
44
+ filename: string;
45
+ }>;
46
+ saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number): Promise<number | null>;
47
+ deleteDraft(accountId: string, draftUid: number): Promise<void>;
48
+ searchContacts(query: string): any[];
49
+ syncGoogleContacts(): Promise<void>;
50
+ seedContacts(): number;
51
+ getSettings(): any;
52
+ saveSettings(settings: any): void;
53
+ getStorageInfo(): {
54
+ provider: string;
55
+ mode: string;
56
+ };
57
+ }
58
+ //# sourceMappingURL=index.d.ts.map