@bobfrankston/mailx-imap 0.1.58 → 0.1.60

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 +35 -0
  2. package/index.js +128 -0
  3. package/package.json +3 -3
package/index.d.ts CHANGED
@@ -491,6 +491,41 @@ export declare class ImapManager extends EventEmitter {
491
491
  startOutboxWorker(): void;
492
492
  /** Stop Outbox worker */
493
493
  stopOutboxWorker(): void;
494
+ /** Periodic Sent sweep — reconciles optimistic local-inserts against
495
+ * the server's actual Sent folder for every IMAP-path account.
496
+ *
497
+ * Why: when a message is sent on an IMAP account, the local DB row is
498
+ * inserted with a PREDICTED UID (the Sent folder's UIDNEXT at submit
499
+ * time). The submission server's drain-Outbox sieve appends the real
500
+ * Sent copy at whatever UID it chooses, which may differ if anything
501
+ * else raced — another client, a sieve append from a different rule,
502
+ * or just the sieve picking its own UID. Without reconciliation:
503
+ * - On the next set-diff sync the local row's UID doesn't match
504
+ * any server UID → set-diff deletes it → "Sent copy missing".
505
+ * - Or the server's actual Sent copy gets reconciled as a NEW row
506
+ * → duplicate in the user's Sent view.
507
+ *
508
+ * What this sweep does:
509
+ * For each IMAP-path account, walk local Sent rows newer than
510
+ * SENT_SWEEP_WINDOW_MS, query the server's Sent folder by
511
+ * Message-ID, and:
512
+ * - if found at the local-known UID → no-op
513
+ * - if found at a different UID → rebind the local row to the
514
+ * real UID
515
+ * - if not found and the .eml is on disk → IMAP APPEND it
516
+ * from disk (no SMTP)
517
+ * - if not found and no .eml → leave it (set-diff will GC; the
518
+ * user has bigger problems than the Sent reconcile)
519
+ *
520
+ * Gmail-path accounts: skipped. Gmail's Sent is API-driven, the
521
+ * optimistic-insert path doesn't apply (gmail-api writes the row
522
+ * directly post-send with the real Gmail message ID, no UID race). */
523
+ private sentSweepInterval;
524
+ private readonly SENT_SWEEP_WINDOW_MS;
525
+ private readonly SENT_SWEEP_INTERVAL_MS;
526
+ startSentSweep(): void;
527
+ stopSentSweep(): void;
528
+ private sweepSentOnce;
494
529
  private configWatchers;
495
530
  private cloudPollTimers;
496
531
  /** Watch the local config files for external changes. On change, emit
package/index.js CHANGED
@@ -2597,6 +2597,14 @@ export class ImapManager extends EventEmitter {
2597
2597
  * the user clicked, they're waiting, this jumps ahead of any background
2598
2598
  * prefetch sitting in the slow lane. */
2599
2599
  async fetchMessageBody(accountId, folderId, uid) {
2600
+ // Belt-and-braces against `UID FETCH 0`. The IMAP server rejects it as
2601
+ // BAD "Invalid uidset" and the connection slot is consumed for the
2602
+ // round-trip. The enqueue path now guards too — this catches direct
2603
+ // callers that bypass the queue (Bob 2026-05-25).
2604
+ if (!uid || !Number.isFinite(uid) || uid <= 0) {
2605
+ console.error(`[imap] fetchMessageBody rejected invalid uid=${uid} for ${accountId}/folder${folderId}`);
2606
+ return null;
2607
+ }
2600
2608
  const envelope = this.db.getMessageByUid(accountId, uid, folderId);
2601
2609
  let storedPath = envelope?.bodyPath || "";
2602
2610
  if (!storedPath)
@@ -4371,6 +4379,126 @@ export class ImapManager extends EventEmitter {
4371
4379
  this.outboxInterval = null;
4372
4380
  }
4373
4381
  }
4382
+ /** Periodic Sent sweep — reconciles optimistic local-inserts against
4383
+ * the server's actual Sent folder for every IMAP-path account.
4384
+ *
4385
+ * Why: when a message is sent on an IMAP account, the local DB row is
4386
+ * inserted with a PREDICTED UID (the Sent folder's UIDNEXT at submit
4387
+ * time). The submission server's drain-Outbox sieve appends the real
4388
+ * Sent copy at whatever UID it chooses, which may differ if anything
4389
+ * else raced — another client, a sieve append from a different rule,
4390
+ * or just the sieve picking its own UID. Without reconciliation:
4391
+ * - On the next set-diff sync the local row's UID doesn't match
4392
+ * any server UID → set-diff deletes it → "Sent copy missing".
4393
+ * - Or the server's actual Sent copy gets reconciled as a NEW row
4394
+ * → duplicate in the user's Sent view.
4395
+ *
4396
+ * What this sweep does:
4397
+ * For each IMAP-path account, walk local Sent rows newer than
4398
+ * SENT_SWEEP_WINDOW_MS, query the server's Sent folder by
4399
+ * Message-ID, and:
4400
+ * - if found at the local-known UID → no-op
4401
+ * - if found at a different UID → rebind the local row to the
4402
+ * real UID
4403
+ * - if not found and the .eml is on disk → IMAP APPEND it
4404
+ * from disk (no SMTP)
4405
+ * - if not found and no .eml → leave it (set-diff will GC; the
4406
+ * user has bigger problems than the Sent reconcile)
4407
+ *
4408
+ * Gmail-path accounts: skipped. Gmail's Sent is API-driven, the
4409
+ * optimistic-insert path doesn't apply (gmail-api writes the row
4410
+ * directly post-send with the real Gmail message ID, no UID race). */
4411
+ sentSweepInterval = null;
4412
+ SENT_SWEEP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
4413
+ SENT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
4414
+ startSentSweep() {
4415
+ if (this.sentSweepInterval)
4416
+ return;
4417
+ const tick = async () => {
4418
+ for (const [accountId] of this.configs) {
4419
+ if (this.isGmailAccount(accountId))
4420
+ continue;
4421
+ try {
4422
+ await this.sweepSentOnce(accountId);
4423
+ }
4424
+ catch (e) {
4425
+ console.error(` [sent-sweep] ${accountId}: ${e?.message || e}`);
4426
+ }
4427
+ }
4428
+ };
4429
+ // First tick deferred 30 s so it doesn't race the daemon's
4430
+ // boot-time sync. Subsequent ticks every SENT_SWEEP_INTERVAL_MS.
4431
+ setTimeout(() => { tick().catch(() => { }); }, 30_000);
4432
+ this.sentSweepInterval = setInterval(() => { tick().catch(() => { }); }, this.SENT_SWEEP_INTERVAL_MS);
4433
+ }
4434
+ stopSentSweep() {
4435
+ if (this.sentSweepInterval) {
4436
+ clearInterval(this.sentSweepInterval);
4437
+ this.sentSweepInterval = null;
4438
+ }
4439
+ }
4440
+ async sweepSentOnce(accountId) {
4441
+ const sent = this.findFolder(accountId, "sent");
4442
+ if (!sent)
4443
+ return;
4444
+ // Pull recent local rows. Restrict to (a) account+folder, (b) cached
4445
+ // in the last hour, (c) have a message_id (no MID = can't reconcile).
4446
+ const cutoff = Date.now() - this.SENT_SWEEP_WINDOW_MS;
4447
+ const rows = this.db.getRecentMessagesByCachedAt(accountId, sent.id, cutoff);
4448
+ if (rows.length === 0)
4449
+ return;
4450
+ let reconciled = 0;
4451
+ let appended = 0;
4452
+ await this.withConnection(accountId, async (client) => {
4453
+ for (const row of rows) {
4454
+ const msgId = row.message_id;
4455
+ if (!msgId)
4456
+ continue;
4457
+ let serverUids = [];
4458
+ try {
4459
+ serverUids = await client.searchByHeader(sent.path, "Message-ID", msgId);
4460
+ }
4461
+ catch (e) {
4462
+ console.error(` [sent-sweep] searchByHeader ${msgId}: ${e?.message || e}`);
4463
+ continue;
4464
+ }
4465
+ if (serverUids.length === 0) {
4466
+ // Not on server. Re-APPEND from .eml if we have one.
4467
+ if (!row.body_path)
4468
+ continue;
4469
+ try {
4470
+ const buf = await this.bodyStore.readByPath(row.body_path);
4471
+ if (!buf)
4472
+ continue;
4473
+ await client.appendMessage(sent.path, buf, ["\\Seen"]);
4474
+ appended++;
4475
+ console.log(` [sent-sweep] ${accountId}: re-APPENDed ${msgId} (was missing on server)`);
4476
+ }
4477
+ catch (e) {
4478
+ console.error(` [sent-sweep] APPEND ${msgId}: ${e?.message || e}`);
4479
+ }
4480
+ }
4481
+ else if (!serverUids.includes(row.uid)) {
4482
+ // Server has it under a different UID — rebind local row by
4483
+ // patching the messages.uid in place. Cheap, no upsert
4484
+ // shuffle, no audit-trail noise.
4485
+ const realUid = serverUids[serverUids.length - 1];
4486
+ try {
4487
+ this.db.updateMessageUid?.(accountId, sent.id, row.uid, realUid);
4488
+ reconciled++;
4489
+ console.log(` [sent-sweep] ${accountId}: rebound ${msgId} ${row.uid} → ${realUid}`);
4490
+ }
4491
+ catch (e) {
4492
+ console.error(` [sent-sweep] rebind ${msgId}: ${e?.message || e}`);
4493
+ }
4494
+ }
4495
+ }
4496
+ }, { slow: true, timeoutMs: 120_000 });
4497
+ if (reconciled + appended > 0) {
4498
+ this.emit("folderCountsChanged", accountId, {});
4499
+ console.log(` [sent-sweep] ${accountId}: ${reconciled} rebound, ${appended} re-appended`);
4500
+ }
4501
+ }
4374
4502
  // ── Config file watcher ──
4375
4503
  configWatchers = [];
4376
4504
  cloudPollTimers = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
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.18",
13
13
  "@bobfrankston/mailx-settings": "^0.1.22",
14
- "@bobfrankston/mailx-store": "^0.1.34",
14
+ "@bobfrankston/mailx-store": "^0.1.36",
15
15
  "@bobfrankston/iflow-direct": "^0.1.50",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
17
  "@bobfrankston/smtp-direct": "^0.1.8",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.18",
41
41
  "@bobfrankston/mailx-settings": "^0.1.22",
42
- "@bobfrankston/mailx-store": "^0.1.34",
42
+ "@bobfrankston/mailx-store": "^0.1.36",
43
43
  "@bobfrankston/iflow-direct": "^0.1.50",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
45
  "@bobfrankston/smtp-direct": "^0.1.8",