@bobfrankston/iflow-direct 0.1.49 → 0.1.51

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/imap-compat.d.ts CHANGED
@@ -160,6 +160,7 @@ export declare class CompatImapClient {
160
160
  watchMailbox(mailbox: string, onNew: (count: number) => void, opts?: {
161
161
  notifySpec?: string;
162
162
  onMailboxStatus?: (mailbox: string, data: proto.StatusData) => void;
163
+ onExpunge?: () => void;
163
164
  }): Promise<() => Promise<void>>;
164
165
  /** Copy a message to another server (cross-account) */
165
166
  moveMessageToServer(msg: any, fromMailbox: string, targetClient: CompatImapClient, toMailbox: string): Promise<void>;
package/imap-compat.js CHANGED
@@ -349,7 +349,7 @@ export class CompatImapClient {
349
349
  this.native.onMailboxStatus = opts.onMailboxStatus;
350
350
  if (opts?.notifySpec)
351
351
  await this.native.notify(opts.notifySpec);
352
- return this.native.startIdle(onNew);
352
+ return this.native.startIdle(onNew, opts?.onExpunge);
353
353
  }
354
354
  /** Copy a message to another server (cross-account) */
355
355
  async moveMessageToServer(msg, fromMailbox, targetClient, toMailbox) {
package/imap-native.d.ts CHANGED
@@ -113,6 +113,12 @@ export declare class NativeImapClient {
113
113
  private _connected;
114
114
  private idleTag;
115
115
  private idleCallback;
116
+ /** Fires when the SELECTED mailbox loses messages while parked in IDLE —
117
+ * an EXISTS decrease, or an unsolicited `* EXPUNGE` / `* VANISHED`
118
+ * (RFC 5465 NOTIFY SELECTED MessageExpunge). Lets the caller reconcile
119
+ * a server-side deletion in real time instead of waiting for the next
120
+ * periodic poll. Distinct from `idleCallback` (new mail only). */
121
+ private idleExpungeCallback;
116
122
  private idleRefreshTimer;
117
123
  /** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
118
124
  * mailboxes (the server pushes these when the client has issued NOTIFY
@@ -257,7 +263,7 @@ export declare class NativeImapClient {
257
263
  * SELECT and BEFORE startIdle — the server holds the spec for the
258
264
  * lifetime of the connection. */
259
265
  notify(spec: string): Promise<void>;
260
- startIdle(onNewMail: (count: number) => void): Promise<() => Promise<void>>;
266
+ startIdle(onNewMail: (count: number) => void, onExpunge?: () => void): Promise<() => Promise<void>>;
261
267
  /**
262
268
  * If IDLE is currently active, send DONE and wait for its tagged OK so the
263
269
  * connection is free to accept a new command. Saves the active callback so
package/imap-native.js CHANGED
@@ -39,6 +39,12 @@ export class NativeImapClient {
39
39
  _connected = false;
40
40
  idleTag = null;
41
41
  idleCallback = null;
42
+ /** Fires when the SELECTED mailbox loses messages while parked in IDLE —
43
+ * an EXISTS decrease, or an unsolicited `* EXPUNGE` / `* VANISHED`
44
+ * (RFC 5465 NOTIFY SELECTED MessageExpunge). Lets the caller reconcile
45
+ * a server-side deletion in real time instead of waiting for the next
46
+ * periodic poll. Distinct from `idleCallback` (new mail only). */
47
+ idleExpungeCallback = null;
42
48
  idleRefreshTimer = null;
43
49
  /** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
44
50
  * mailboxes (the server pushes these when the client has issued NOTIFY
@@ -126,8 +132,29 @@ export class NativeImapClient {
126
132
  }
127
133
  });
128
134
  this.transport.onError((err) => {
135
+ // Diagnose transport-level errors fully. Node emits socket errors
136
+ // with a populated `.message` in normal cases (ECONNRESET, ETIMEDOUT,
137
+ // etc.) — but TLS-layer errors, abrupt FINs surfaced as errors,
138
+ // and some wrapper paths produce an Error with empty `.message`
139
+ // and the useful info on `.code` / `.errno` / `.syscall` instead.
140
+ // Logging only `.message` left "Transport error: " in the log
141
+ // with no diagnostic content, which masked why bobma INBOX prefetch
142
+ // kept dying (2026-05-27). Build a richer description.
143
+ const errAny = err;
144
+ const parts = [];
145
+ if (errAny?.message)
146
+ parts.push(String(errAny.message));
147
+ if (errAny?.code)
148
+ parts.push(`code=${errAny.code}`);
149
+ if (errAny?.errno !== undefined)
150
+ parts.push(`errno=${errAny.errno}`);
151
+ if (errAny?.syscall)
152
+ parts.push(`syscall=${errAny.syscall}`);
153
+ const desc = parts.length > 0
154
+ ? parts.join(" ")
155
+ : `<no message> ${typeof err} ${err?.constructor?.name || ""}`.trim();
129
156
  if (this.verbose)
130
- console.error(` [imap] Transport error: ${err.message}`);
157
+ console.error(` [imap] Transport error: ${desc}`);
131
158
  // Transport errors (DNS failure, ECONNRESET, TLS failure, etc.) mean
132
159
  // the connection is dead — clear the flag so ensureConnected() will
133
160
  // reconnect on the next call, and drop the stale IDLE state so we
@@ -143,7 +170,17 @@ export class NativeImapClient {
143
170
  if (this.pendingCommand) {
144
171
  const { reject } = this.pendingCommand;
145
172
  this.pendingCommand = null;
146
- reject(err);
173
+ // Make sure the error that propagates to upper-layer catches
174
+ // (prefetch, sync, etc.) has a non-empty message — wrap if
175
+ // the underlying err has a blank message.
176
+ if (err instanceof Error && !err.message) {
177
+ const wrapped = new Error(desc);
178
+ wrapped.cause = err;
179
+ reject(wrapped);
180
+ }
181
+ else {
182
+ reject(err);
183
+ }
147
184
  }
148
185
  });
149
186
  await this.transport.connect(this.config.server, this.config.port, useTls, this.config.server);
@@ -731,8 +768,9 @@ export class NativeImapClient {
731
768
  }
732
769
  }
733
770
  // ── IDLE ──
734
- async startIdle(onNewMail) {
771
+ async startIdle(onNewMail, onExpunge) {
735
772
  this.idleCallback = onNewMail;
773
+ this.idleExpungeCallback = onExpunge ?? null;
736
774
  this.idleStopped = false;
737
775
  const beginIdleCycle = async () => {
738
776
  const tag = proto.nextTag();
@@ -764,6 +802,7 @@ export class NativeImapClient {
764
802
  const tag = this.idleTag;
765
803
  this.idleTag = null;
766
804
  this.idleCallback = null;
805
+ this.idleExpungeCallback = null;
767
806
  if (tag) {
768
807
  // Arm tagged-wait BEFORE DONE — avoid missing the OK.
769
808
  const waitTag = this.waitForTagged(tag);
@@ -1291,6 +1330,24 @@ export class NativeImapClient {
1291
1330
  this.mailboxInfo.exists = count;
1292
1331
  this.idleCallback(newCount);
1293
1332
  }
1333
+ else if (count < this.mailboxInfo.exists) {
1334
+ // Count dropped — a message was expunged on the selected
1335
+ // mailbox (another client / Thunderbird deleted it).
1336
+ // Signal the caller to reconcile the deletion now.
1337
+ this.mailboxInfo.exists = count;
1338
+ if (this.idleExpungeCallback)
1339
+ this.idleExpungeCallback();
1340
+ }
1341
+ continue;
1342
+ }
1343
+ // During IDLE, an unsolicited `* n EXPUNGE` / `* VANISHED` for the
1344
+ // selected mailbox (RFC 5465 NOTIFY SELECTED MessageExpunge, or a
1345
+ // plain post-EXPUNGE notification). Push a deletion reconcile.
1346
+ if (this.idleTag && resp.tag === "*" && (resp.type === "EXPUNGE" || resp.type === "VANISHED")) {
1347
+ if (this.mailboxInfo.exists > 0)
1348
+ this.mailboxInfo.exists--;
1349
+ if (this.idleExpungeCallback)
1350
+ this.idleExpungeCallback();
1294
1351
  continue;
1295
1352
  }
1296
1353
  // RFC 5465 NOTIFY: unsolicited STATUS responses for non-selected
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",