@bobfrankston/iflow-direct 0.1.41 → 0.1.43

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
@@ -106,6 +106,42 @@ export declare class CompatImapClient {
106
106
  /** Cached CAPABILITY set parsed at connect/login. Callers gate
107
107
  * optional features (NOTIFY, QRESYNC, MOVE, ...) on this. */
108
108
  getCapabilities(): Set<string>;
109
+ /** RFC 5161 ENABLE — activates an IMAP extension for the remainder of
110
+ * the session. QRESYNC must be enabled before any SELECT for
111
+ * `* VANISHED` responses + automatic `MODSEQ` to start flowing.
112
+ * Returns the set of extensions the server confirmed are active. */
113
+ enable(extensions: string[]): Promise<Set<string>>;
114
+ /** Convenience: enable QRESYNC if the server advertises it. Returns
115
+ * true if QRESYNC is active on the session afterwards. Idempotent —
116
+ * safe to call once per connection at connect time. */
117
+ enableQresync(): Promise<boolean>;
118
+ /** Fast resync of a mailbox via RFC 7162 QRESYNC. Caller supplies the
119
+ * `uidValidity` and last-seen `modSeq` from its prior visit; the
120
+ * server replies with `* VANISHED` for UIDs expunged since then and
121
+ * unsolicited `* FETCH` for state changes (flag updates etc.) since
122
+ * then, plus the current `HIGHESTMODSEQ` for the caller to persist
123
+ * as its new watermark.
124
+ *
125
+ * Returns:
126
+ * - `uidValidityChanged`: server's UIDVALIDITY no longer matches; the
127
+ * caller's UID set is stale and MUST be full-resynced.
128
+ * - `vanishedUids`: authoritative deletion list. No client-side diff.
129
+ * - `changedMessages`: state-change FETCH responses since the prior
130
+ * modSeq — each carries `uid`, `flags`, and `modSeq` (and possibly
131
+ * other FETCH items the caller asked for).
132
+ * - `newHighestModSeq`: persist this for the next resync.
133
+ *
134
+ * Pre-requisite: `enableQresync()` returned true once on the session.
135
+ * If the server doesn't support QRESYNC, fall back to the older
136
+ * `fetchMessagesSinceUid` / set-diff path. */
137
+ resyncFolder(mailbox: string, uidValidity: number, modSeq: number): Promise<{
138
+ uidValidityChanged: boolean;
139
+ vanishedUids: number[];
140
+ changedMessages: FetchedMessage[];
141
+ newHighestModSeq: number | undefined;
142
+ exists: number;
143
+ uidNext: number;
144
+ }>;
109
145
  /** Watch a mailbox for new messages (IDLE). Optionally engage RFC 5465
110
146
  * NOTIFY so the server also pushes STATUS responses for non-selected
111
147
  * mailboxes named in `opts.notifySpec` — `opts.onMailboxStatus` fires
package/imap-compat.js CHANGED
@@ -279,6 +279,61 @@ export class CompatImapClient {
279
279
  getCapabilities() {
280
280
  return this.native.getCapabilities();
281
281
  }
282
+ /** RFC 5161 ENABLE — activates an IMAP extension for the remainder of
283
+ * the session. QRESYNC must be enabled before any SELECT for
284
+ * `* VANISHED` responses + automatic `MODSEQ` to start flowing.
285
+ * Returns the set of extensions the server confirmed are active. */
286
+ async enable(extensions) {
287
+ await this.ensureConnected();
288
+ return this.native.enable(extensions);
289
+ }
290
+ /** Convenience: enable QRESYNC if the server advertises it. Returns
291
+ * true if QRESYNC is active on the session afterwards. Idempotent —
292
+ * safe to call once per connection at connect time. */
293
+ async enableQresync() {
294
+ const caps = this.getCapabilities();
295
+ if (!caps.has("QRESYNC"))
296
+ return false;
297
+ const enabled = await this.enable(["QRESYNC"]);
298
+ return enabled.has("QRESYNC");
299
+ }
300
+ /** Fast resync of a mailbox via RFC 7162 QRESYNC. Caller supplies the
301
+ * `uidValidity` and last-seen `modSeq` from its prior visit; the
302
+ * server replies with `* VANISHED` for UIDs expunged since then and
303
+ * unsolicited `* FETCH` for state changes (flag updates etc.) since
304
+ * then, plus the current `HIGHESTMODSEQ` for the caller to persist
305
+ * as its new watermark.
306
+ *
307
+ * Returns:
308
+ * - `uidValidityChanged`: server's UIDVALIDITY no longer matches; the
309
+ * caller's UID set is stale and MUST be full-resynced.
310
+ * - `vanishedUids`: authoritative deletion list. No client-side diff.
311
+ * - `changedMessages`: state-change FETCH responses since the prior
312
+ * modSeq — each carries `uid`, `flags`, and `modSeq` (and possibly
313
+ * other FETCH items the caller asked for).
314
+ * - `newHighestModSeq`: persist this for the next resync.
315
+ *
316
+ * Pre-requisite: `enableQresync()` returned true once on the session.
317
+ * If the server doesn't support QRESYNC, fall back to the older
318
+ * `fetchMessagesSinceUid` / set-diff path. */
319
+ async resyncFolder(mailbox, uidValidity, modSeq) {
320
+ await this.ensureConnected();
321
+ const result = await this.native.select(mailbox, { uidValidity, modSeq });
322
+ // Untagged FETCH responses emitted during a QRESYNC SELECT are
323
+ // pre-parsed by the native layer into NativeFetchedMessage objects;
324
+ // expose them as FetchedMessage to compat callers. (The native
325
+ // select() doesn't currently surface these — that wiring is in
326
+ // imap-native.ts:select via the FETCH-during-select case.)
327
+ const changedMessages = result.changedMessages?.map((m) => new FetchedMessage(m)) || [];
328
+ return {
329
+ uidValidityChanged: result.uidValidityChanged,
330
+ vanishedUids: result.vanishedUids,
331
+ changedMessages,
332
+ newHighestModSeq: result.highestModSeq,
333
+ exists: result.exists,
334
+ uidNext: result.uidNext,
335
+ };
336
+ }
282
337
  /** Watch a mailbox for new messages (IDLE). Optionally engage RFC 5465
283
338
  * NOTIFY so the server also pushes STATUS responses for non-selected
284
339
  * mailboxes named in `opts.notifySpec` — `opts.onMailboxStatus` fires
package/imap-native.d.ts CHANGED
@@ -30,6 +30,11 @@ export interface NativeFetchedMessage {
30
30
  flagged: boolean;
31
31
  answered: boolean;
32
32
  draft: boolean;
33
+ /** Per-message modification sequence (RFC 7162). Present when the
34
+ * server is CONDSTORE/QRESYNC-capable and the client either has
35
+ * ENABLE QRESYNC active or asked for MODSEQ explicitly. Used by the
36
+ * caller to track its own `last_modseq` watermark per folder. */
37
+ modSeq?: number;
33
38
  }
34
39
  export interface NativeFolder {
35
40
  path: string;
@@ -43,6 +48,33 @@ export interface MailboxInfo {
43
48
  uidValidity: number;
44
49
  flags: string[];
45
50
  permanentFlags: string[];
51
+ /** Per RFC 7162. Present when the server is CONDSTORE/QRESYNC-capable
52
+ * and includes [HIGHESTMODSEQ N] in the SELECT-OK response (or after
53
+ * ENABLE QRESYNC). Use as the `since-modseq` argument to subsequent
54
+ * resyncs to ask the server "what changed since this point?" */
55
+ highestModSeq?: number;
56
+ }
57
+ /** Parameters for `SELECT mailbox (QRESYNC (uidvalidity modseq))` per RFC 7162.
58
+ * Caller persists `(uidValidity, modSeq)` per folder; after ENABLE QRESYNC
59
+ * has been issued once on the session, the next SELECT replies with
60
+ * `* VANISHED (EARLIER) <uids>` for every message the server expunged since
61
+ * `modSeq`, plus `* FETCH` for every message whose state changed since then.
62
+ * No client-side UID diffing, no tombstones — the server tells you the
63
+ * authoritative deletions. */
64
+ export interface QresyncParams {
65
+ uidValidity: number;
66
+ modSeq: number;
67
+ knownUids?: string;
68
+ }
69
+ /** Result of a QRESYNC-enabled SELECT. The caller drains `vanishedUids`
70
+ * (delete from local store), processes any FETCH responses (upsert state
71
+ * changes), and saves the new `highestModSeq` for the next resync. */
72
+ export interface SelectResult extends MailboxInfo {
73
+ vanishedUids: number[];
74
+ /** True if the server's UIDVALIDITY changed since the caller's last
75
+ * visit — in that case knownUids/modSeq are invalid and the caller
76
+ * must full-resync from scratch. */
77
+ uidValidityChanged: boolean;
46
78
  }
47
79
  export declare class NativeImapClient {
48
80
  private transport;
@@ -116,7 +148,21 @@ export declare class NativeImapClient {
116
148
  getCapabilities(): Set<string>;
117
149
  private parseCapabilities;
118
150
  logout(): Promise<void>;
119
- select(mailbox: string): Promise<MailboxInfo>;
151
+ /** SELECT a mailbox. Optional QRESYNC params trigger RFC 7162 fast resync —
152
+ * on a re-visit the server replies with `* VANISHED (EARLIER) <uids>` for
153
+ * every UID expunged since the caller's last `modSeq`, plus `* FETCH`
154
+ * for every state change since then. The caller must have called
155
+ * `enable(["QRESYNC"])` once on the session for this to take effect.
156
+ * Returns a `SelectResult` rather than a bare `MailboxInfo` so VANISHED
157
+ * is delivered to the caller. The result is upcast-compatible with
158
+ * `MailboxInfo` for code paths that don't care about VANISHED. */
159
+ select(mailbox: string, qresync?: QresyncParams): Promise<SelectResult>;
160
+ /** RFC 5161 ENABLE. Activates an extension for the rest of the session.
161
+ * QRESYNC requires this once before any SELECT for VANISHED responses
162
+ * to be emitted. Capability-gate at the caller — emitting ENABLE for
163
+ * an unadvertised extension is a no-op on compliant servers but spams
164
+ * the response stream. */
165
+ enable(extensions: string[]): Promise<Set<string>>;
120
166
  examine(mailbox: string): Promise<MailboxInfo>;
121
167
  /** Close the currently selected mailbox */
122
168
  closeMailbox(): Promise<void>;
package/imap-native.js CHANGED
@@ -251,14 +251,26 @@ export class NativeImapClient {
251
251
  this._connected = false;
252
252
  }
253
253
  // ── Mailbox Operations ──
254
- async select(mailbox) {
254
+ /** SELECT a mailbox. Optional QRESYNC params trigger RFC 7162 fast resync —
255
+ * on a re-visit the server replies with `* VANISHED (EARLIER) <uids>` for
256
+ * every UID expunged since the caller's last `modSeq`, plus `* FETCH`
257
+ * for every state change since then. The caller must have called
258
+ * `enable(["QRESYNC"])` once on the session for this to take effect.
259
+ * Returns a `SelectResult` rather than a bare `MailboxInfo` so VANISHED
260
+ * is delivered to the caller. The result is upcast-compatible with
261
+ * `MailboxInfo` for code paths that don't care about VANISHED. */
262
+ async select(mailbox, qresync) {
255
263
  const tag = proto.nextTag();
256
- const responses = await this.sendCommand(tag, proto.selectCommand(tag, mailbox));
264
+ const responses = await this.sendCommand(tag, proto.selectCommand(tag, mailbox, qresync));
257
265
  const tagged = responses.find(r => r.tag === tag);
258
266
  if (!tagged || tagged.type !== "OK") {
259
267
  throw new Error(`SELECT ${mailbox} failed: ${tagged?.text || "unknown"}`);
260
268
  }
261
- // Parse mailbox info from untagged responses
269
+ // Reset transient fields each SELECT so a stale value from a previous
270
+ // mailbox doesn't leak into the new one.
271
+ this.mailboxInfo.highestModSeq = undefined;
272
+ const vanishedUids = [];
273
+ const priorUidValidity = qresync?.uidValidity;
262
274
  for (const r of responses) {
263
275
  if (r.tag !== "*")
264
276
  continue;
@@ -281,10 +293,50 @@ export class NativeImapClient {
281
293
  const permMatch = r.text.match(/PERMANENTFLAGS\s+\(([^)]*)\)/i);
282
294
  if (permMatch)
283
295
  this.mailboxInfo.permanentFlags = permMatch[1].split(/\s+/).filter(Boolean);
296
+ const modSeqMatch = r.text.match(/HIGHESTMODSEQ\s+(\d+)/i);
297
+ if (modSeqMatch)
298
+ this.mailboxInfo.highestModSeq = parseInt(modSeqMatch[1]);
299
+ }
300
+ else if (r.type === "VANISHED") {
301
+ // `* VANISHED (EARLIER) 1:5,8,12` or `* VANISHED 1:5,8,12` —
302
+ // strip the optional `(EARLIER)` modifier and expand the UID
303
+ // set. EARLIER means "deleted before your last sync", which
304
+ // is the QRESYNC case; without it, the message just vanished
305
+ // mid-session (post-EXPUNGE on another client).
306
+ const text = r.text.replace(/^\(EARLIER\)\s*/i, "").trim();
307
+ for (const uid of proto.parseUidSet(text))
308
+ vanishedUids.push(uid);
284
309
  }
285
310
  }
286
311
  this.selectedMailbox = mailbox;
287
- return { ...this.mailboxInfo };
312
+ const uidValidityChanged = priorUidValidity !== undefined
313
+ && this.mailboxInfo.uidValidity !== priorUidValidity;
314
+ return {
315
+ ...this.mailboxInfo,
316
+ vanishedUids,
317
+ uidValidityChanged,
318
+ };
319
+ }
320
+ /** RFC 5161 ENABLE. Activates an extension for the rest of the session.
321
+ * QRESYNC requires this once before any SELECT for VANISHED responses
322
+ * to be emitted. Capability-gate at the caller — emitting ENABLE for
323
+ * an unadvertised extension is a no-op on compliant servers but spams
324
+ * the response stream. */
325
+ async enable(extensions) {
326
+ const tag = proto.nextTag();
327
+ const responses = await this.sendCommand(tag, proto.enableCommand(tag, extensions));
328
+ const tagged = responses.find(r => r.tag === tag);
329
+ if (!tagged || tagged.type !== "OK") {
330
+ throw new Error(`ENABLE ${extensions.join(" ")} failed: ${tagged?.text || "unknown"}`);
331
+ }
332
+ const enabled = new Set();
333
+ for (const r of responses) {
334
+ if (r.tag === "*" && r.type === "ENABLED") {
335
+ for (const ext of r.text.split(/\s+/).filter(Boolean))
336
+ enabled.add(ext.toUpperCase());
337
+ }
338
+ }
339
+ return enabled;
288
340
  }
289
341
  async examine(mailbox) {
290
342
  const tag = proto.nextTag();
@@ -1284,6 +1336,14 @@ export class NativeImapClient {
1284
1336
  const sizeMatch = r.text.match(/RFC822\.SIZE\s+(\d+)/);
1285
1337
  if (sizeMatch)
1286
1338
  msg.size = parseInt(sizeMatch[1]);
1339
+ // Extract MODSEQ (RFC 7162). Format: `MODSEQ (12345)`. Server
1340
+ // emits this when the session has ENABLE QRESYNC/CONDSTORE
1341
+ // active OR when the client explicitly asks for MODSEQ in the
1342
+ // FETCH command. Track on every FETCH so the caller can update
1343
+ // its `last_modseq` watermark.
1344
+ const modSeqMatch = r.text.match(/MODSEQ\s+\((\d+)\)/);
1345
+ if (modSeqMatch)
1346
+ msg.modSeq = parseInt(modSeqMatch[1]);
1287
1347
  // Extract INTERNALDATE
1288
1348
  const dateMatch = r.text.match(/INTERNALDATE\s+"([^"]+)"/);
1289
1349
  if (dateMatch)
@@ -71,8 +71,26 @@ export declare function loginCommand(tag: string, user: string, pass: string): s
71
71
  export declare function xoauth2Command(tag: string, user: string, token: string): string;
72
72
  /** Build LIST command */
73
73
  export declare function listCommand(tag: string, ref?: string, pattern?: string): string;
74
- /** Build SELECT command */
75
- export declare function selectCommand(tag: string, mailbox: string): string;
74
+ /** Build SELECT command. Optional `qresync` triggers RFC 7162 fast resync:
75
+ * the server emits `* VANISHED (EARLIER) <uid-set>` for every UID expunged
76
+ * since `modSeq` and `* FETCH` for every state change since then, in lieu
77
+ * of the caller having to diff UID sets. Capability-gated by caller;
78
+ * requires `ENABLE QRESYNC` already issued on the session. */
79
+ export declare function selectCommand(tag: string, mailbox: string, qresync?: {
80
+ uidValidity: number;
81
+ modSeq: number;
82
+ knownUids?: string;
83
+ }): string;
84
+ /** Build ENABLE command per RFC 5161. Common extensions to enable: QRESYNC,
85
+ * CONDSTORE, UTF8=ACCEPT. Must be issued before any SELECT for the
86
+ * extension to take effect on the session. */
87
+ export declare function enableCommand(tag: string, extensions: string[]): string;
88
+ /** Expand an RFC 3501 UID set (e.g. `"1:5,8,12,20:*"`) into a flat number
89
+ * list. Does NOT resolve `*` — caller passes a known upper bound only
90
+ * when it has one; here `*` is treated as a sentinel and skipped so the
91
+ * call site can decide what to do (typically: ignore — VANISHED never
92
+ * emits `*` because that would be open-ended). */
93
+ export declare function parseUidSet(set: string): number[];
76
94
  /** Build EXAMINE command (read-only SELECT) */
77
95
  export declare function examineCommand(tag: string, mailbox: string): string;
78
96
  /** Build STATUS command */
package/imap-protocol.js CHANGED
@@ -32,9 +32,52 @@ export function xoauth2Command(tag, user, token) {
32
32
  export function listCommand(tag, ref = '""', pattern = '"*"') {
33
33
  return buildCommand(tag, `LIST ${ref} ${pattern}`);
34
34
  }
35
- /** Build SELECT command */
36
- export function selectCommand(tag, mailbox) {
37
- return buildCommand(tag, `SELECT ${quoteMailbox(mailbox)}`);
35
+ /** Build SELECT command. Optional `qresync` triggers RFC 7162 fast resync:
36
+ * the server emits `* VANISHED (EARLIER) <uid-set>` for every UID expunged
37
+ * since `modSeq` and `* FETCH` for every state change since then, in lieu
38
+ * of the caller having to diff UID sets. Capability-gated by caller;
39
+ * requires `ENABLE QRESYNC` already issued on the session. */
40
+ export function selectCommand(tag, mailbox, qresync) {
41
+ if (!qresync)
42
+ return buildCommand(tag, `SELECT ${quoteMailbox(mailbox)}`);
43
+ let params = `${qresync.uidValidity} ${qresync.modSeq}`;
44
+ if (qresync.knownUids)
45
+ params += ` ${qresync.knownUids}`;
46
+ return buildCommand(tag, `SELECT ${quoteMailbox(mailbox)} (QRESYNC (${params}))`);
47
+ }
48
+ /** Build ENABLE command per RFC 5161. Common extensions to enable: QRESYNC,
49
+ * CONDSTORE, UTF8=ACCEPT. Must be issued before any SELECT for the
50
+ * extension to take effect on the session. */
51
+ export function enableCommand(tag, extensions) {
52
+ return buildCommand(tag, `ENABLE ${extensions.join(" ")}`);
53
+ }
54
+ /** Expand an RFC 3501 UID set (e.g. `"1:5,8,12,20:*"`) into a flat number
55
+ * list. Does NOT resolve `*` — caller passes a known upper bound only
56
+ * when it has one; here `*` is treated as a sentinel and skipped so the
57
+ * call site can decide what to do (typically: ignore — VANISHED never
58
+ * emits `*` because that would be open-ended). */
59
+ export function parseUidSet(set) {
60
+ const out = [];
61
+ for (const part of set.split(",")) {
62
+ const trimmed = part.trim();
63
+ if (!trimmed)
64
+ continue;
65
+ const range = trimmed.split(":");
66
+ if (range.length === 1) {
67
+ const n = parseInt(range[0], 10);
68
+ if (Number.isFinite(n))
69
+ out.push(n);
70
+ }
71
+ else if (range.length === 2) {
72
+ const lo = parseInt(range[0], 10);
73
+ const hi = range[1] === "*" ? lo : parseInt(range[1], 10);
74
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi < lo)
75
+ continue;
76
+ for (let u = lo; u <= hi; u++)
77
+ out.push(u);
78
+ }
79
+ }
80
+ return out;
38
81
  }
39
82
  /** Build EXAMINE command (read-only SELECT) */
40
83
  export function examineCommand(tag, mailbox) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",