@bobfrankston/mailx-imap 0.1.91 → 0.1.93

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.
Files changed (3) hide show
  1. package/index.d.ts +15 -0
  2. package/index.js +156 -8
  3. package/package.json +3 -3
package/index.d.ts CHANGED
@@ -120,6 +120,21 @@ export declare class ImapManager extends EventEmitter {
120
120
  searchAndFetchOnServer(accountId: string, folderId: number, mailboxPath: string, criteria: any): Promise<number[]>;
121
121
  /** Create a fresh IMAP client for an account (public access for API endpoints) */
122
122
  createPublicClient(accountId: string): Promise<any>;
123
+ /** Create `fullPath` on the server, upsert the local folder row, re-sync
124
+ * the folder list, and report whether the folder actually exists after. */
125
+ createFolderViaServer(accountId: string, fullPath: string, name: string, delimiter: string): Promise<boolean>;
126
+ /** Rename `oldPath` → `newPath` on the server and re-sync folders. */
127
+ renameFolderViaServer(accountId: string, oldPath: string, newPath: string): Promise<void>;
128
+ /** Delete `path` on the server (tolerating already-gone). Local folder-row
129
+ * removal stays with the caller (it knows the folderId). */
130
+ deleteFolderViaServer(accountId: string, path: string): Promise<void>;
131
+ /** Expunge every message in `path` on the server. */
132
+ emptyFolderViaServer(accountId: string, path: string): Promise<void>;
133
+ /** Move a folder into Trash by IMAP RENAME, falling back to spilling its
134
+ * messages into Trash root + deleting the empty folder when the server
135
+ * forbids nesting under Trash. Self-contained on the worker. */
136
+ moveFolderToTrashViaServer(accountId: string, folderId: number, folderPath: string, targetPath: string, trashId: number, trashPath: string, delim: string): Promise<void>;
137
+ private spillFolderToTrashServer;
123
138
  /** Persistent slow-lane operational connection per account. Used by sync,
124
139
  * prefetch, outbox-append, large backfills, and any other "this might
125
140
  * take a while" operation. */
package/index.js CHANGED
@@ -408,6 +408,152 @@ export class ImapManager extends EventEmitter {
408
408
  async createPublicClient(accountId) {
409
409
  return this.createClient(accountId);
410
410
  }
411
+ // ── Server-side folder operations ──────────────────────────────────────
412
+ // These run the IMAP folder mutation AND the local folder-table write here
413
+ // (on whatever thread ImapManager lives — the sync worker), so the service
414
+ // never needs a live IMAP client object across the worker boundary. The
415
+ // service keeps the surrounding UX (path computation, error messaging); it
416
+ // calls these for the actual server+folder-row work. Returns are primitive
417
+ // so they cross the bus cleanly.
418
+ /** Create `fullPath` on the server, upsert the local folder row, re-sync
419
+ * the folder list, and report whether the folder actually exists after. */
420
+ async createFolderViaServer(accountId, fullPath, name, delimiter) {
421
+ const client = await this.createPublicClient(accountId);
422
+ try {
423
+ await client.createmailbox(fullPath);
424
+ this.db.upsertFolder(accountId, fullPath, name, null, delimiter);
425
+ await this.syncFolders(accountId, client);
426
+ return this.db.getFolders(accountId).some(f => f.path === fullPath);
427
+ }
428
+ finally {
429
+ try {
430
+ await client.logout();
431
+ }
432
+ catch { /* */ }
433
+ }
434
+ }
435
+ /** Rename `oldPath` → `newPath` on the server and re-sync folders. */
436
+ async renameFolderViaServer(accountId, oldPath, newPath) {
437
+ const client = await this.createPublicClient(accountId);
438
+ try {
439
+ if (client.renameMailbox)
440
+ await client.renameMailbox(oldPath, newPath);
441
+ else
442
+ await client.withConnection(async () => { await client.client.mailboxRename(oldPath, newPath); });
443
+ await this.syncFolders(accountId, client);
444
+ }
445
+ finally {
446
+ try {
447
+ await client.logout();
448
+ }
449
+ catch { /* */ }
450
+ }
451
+ }
452
+ /** Delete `path` on the server (tolerating already-gone). Local folder-row
453
+ * removal stays with the caller (it knows the folderId). */
454
+ async deleteFolderViaServer(accountId, path) {
455
+ const client = await this.createPublicClient(accountId);
456
+ try {
457
+ try {
458
+ if (client.deleteMailbox)
459
+ await client.deleteMailbox(path);
460
+ else
461
+ await client.withConnection(async () => { await client.client.mailboxDelete(path); });
462
+ }
463
+ catch (e) {
464
+ const msg = String(e?.message || e || "").toLowerCase();
465
+ if (!/nonexistent|does not exist|no such|not found|404/i.test(msg))
466
+ throw e;
467
+ console.log(` [folder] ${accountId} delete "${path}": server says already gone`);
468
+ }
469
+ }
470
+ finally {
471
+ try {
472
+ await client.logout();
473
+ }
474
+ catch { /* */ }
475
+ }
476
+ }
477
+ /** Expunge every message in `path` on the server. */
478
+ async emptyFolderViaServer(accountId, path) {
479
+ const client = await this.createPublicClient(accountId);
480
+ try {
481
+ const uids = await client.getUids(path);
482
+ for (const uid of uids)
483
+ await client.deleteMessageByUid(path, uid);
484
+ }
485
+ finally {
486
+ try {
487
+ await client.logout();
488
+ }
489
+ catch { /* */ }
490
+ }
491
+ }
492
+ /** Move a folder into Trash by IMAP RENAME, falling back to spilling its
493
+ * messages into Trash root + deleting the empty folder when the server
494
+ * forbids nesting under Trash. Self-contained on the worker. */
495
+ async moveFolderToTrashViaServer(accountId, folderId, folderPath, targetPath, trashId, trashPath, delim) {
496
+ const client = await this.createPublicClient(accountId);
497
+ try {
498
+ try {
499
+ if (client.renameMailbox)
500
+ await client.renameMailbox(folderPath, targetPath);
501
+ else
502
+ await client.withConnection(async () => { await client.client.mailboxRename(folderPath, targetPath); });
503
+ console.log(` [folder] ${accountId} moved "${folderPath}" → "${targetPath}"`);
504
+ }
505
+ catch (e) {
506
+ const msg = String(e?.message || e || "").toLowerCase();
507
+ if (!/noinferiors|hierarchy|invalid (mailbox )?name|cannot rename|inferiors/i.test(msg))
508
+ throw e;
509
+ console.log(` [folder] ${accountId} cannot RENAME under Trash; spilling messages`);
510
+ await this.spillFolderToTrashServer(accountId, folderId, folderPath, trashId, trashPath, delim, client);
511
+ }
512
+ await this.syncFolders(accountId, client);
513
+ }
514
+ finally {
515
+ try {
516
+ await client.logout();
517
+ }
518
+ catch { /* */ }
519
+ }
520
+ }
521
+ async spillFolderToTrashServer(accountId, folderId, folderPath, trashId, trashPath, delim, client) {
522
+ // Children first so the parent ends up empty.
523
+ const childPrefix = folderPath + delim;
524
+ const children = this.db.getFolders(accountId).filter(f => f.path.startsWith(childPrefix));
525
+ for (const child of children) {
526
+ await this.spillFolderToTrashServer(accountId, child.id, child.path, trashId, trashPath, delim, client);
527
+ }
528
+ // Move each message to Trash on the server, then mirror locally.
529
+ const uids = await client.getUids(folderPath).catch(() => []);
530
+ for (const uid of uids) {
531
+ try {
532
+ if (client.moveMessage)
533
+ await client.moveMessage(folderPath, trashPath, uid);
534
+ else {
535
+ await client.copyMessage(folderPath, trashPath, uid);
536
+ await client.deleteMessageByUid(folderPath, uid);
537
+ }
538
+ this.store.trashMessage(accountId, uid, folderId, trashId);
539
+ }
540
+ catch (e) {
541
+ console.error(` [folder] spill uid ${uid}: ${e?.message || e}`);
542
+ }
543
+ }
544
+ try {
545
+ if (client.deleteMailbox)
546
+ await client.deleteMailbox(folderPath);
547
+ else
548
+ await client.withConnection(async () => { await client.client.mailboxDelete(folderPath); });
549
+ }
550
+ catch (e) {
551
+ const m = String(e?.message || e || "").toLowerCase();
552
+ if (!/nonexistent|does not exist|no such/.test(m))
553
+ throw e;
554
+ }
555
+ this.db.deleteFolder(folderId);
556
+ }
411
557
  // Legacy fallback disabled — was doubling connections without helping.
412
558
  // To re-enable: uncomment legacyFallbacks logic in createClient and _syncAll.
413
559
  // private legacyFallbacks = new Set<string>();
@@ -636,14 +782,16 @@ export class ImapManager extends EventEmitter {
636
782
  const releaseHostSlot = skipSemaphore ? (() => { }) : await this.acquireHostSlot(host);
637
783
  let client;
638
784
  try {
639
- // Verbose IMAP wire trace for ops connections only — that's the
640
- // lane where commands have been hanging silently with no
641
- // heartbeat / wall-clock fire / reject. Need to see the actual
642
- // commands sent and bytes received to pinpoint where the
643
- // pendingCommand is getting lost. Fast lane (C123) shares the
644
- // verbose treatment so click-time wedges show too. Other lanes
645
- // (idle, quickCheck) stay quiet so the log doesn't drown.
646
- const cfgWithVerbose = (purpose === "ops" || purpose === "fast") ? { ...config, verbose: true } : config;
785
+ // Verbose IMAP wire trace diagnostic for silently-hanging
786
+ // commands. It was left permanently ON for the ops/fast lanes, but
787
+ // that logs every literal chunk (8000+ lines during a body-fetch
788
+ // burst), and on Windows the daemon's console.log writes to the log
789
+ // file SYNCHRONOUSLY so the trace itself blocked the main event
790
+ // loop during sync, starving the IPC reply relay and producing the
791
+ // 78s read stalls (Bob 2026-06-13). Now opt-in: set RMFMAIL_IMAP_TRACE=1
792
+ // to re-enable for a debugging session. Default is quiet.
793
+ const imapTrace = process.env.RMFMAIL_IMAP_TRACE === "1";
794
+ const cfgWithVerbose = (imapTrace && (purpose === "ops" || purpose === "fast")) ? { ...config, verbose: true } : config;
647
795
  client = new CompatImapClient(cfgWithVerbose, this.transportFactory);
648
796
  }
649
797
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -11,7 +11,7 @@
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.19",
13
13
  "@bobfrankston/mailx-settings": "^0.1.26",
14
- "@bobfrankston/mailx-store": "^0.1.47",
14
+ "@bobfrankston/mailx-store": "^0.1.48",
15
15
  "@bobfrankston/iflow-direct": "^0.1.53",
16
16
  "@bobfrankston/tcp-transport": "^0.1.7",
17
17
  "@bobfrankston/smtp-direct": "^0.1.9",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.19",
41
41
  "@bobfrankston/mailx-settings": "^0.1.26",
42
- "@bobfrankston/mailx-store": "^0.1.47",
42
+ "@bobfrankston/mailx-store": "^0.1.48",
43
43
  "@bobfrankston/iflow-direct": "^0.1.53",
44
44
  "@bobfrankston/tcp-transport": "^0.1.7",
45
45
  "@bobfrankston/smtp-direct": "^0.1.9",