@bobfrankston/iflow-direct 0.1.39 → 0.1.40

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
@@ -7,6 +7,7 @@ import { type NativeFolder } from "./imap-native.js";
7
7
  import { FetchedMessage } from "./fetched-message.js";
8
8
  import type { ImapClientConfig } from "./types.js";
9
9
  import type { TransportFactory } from "./transport.js";
10
+ import type * as proto from "./imap-protocol.js";
10
11
  /** Special folder detection result */
11
12
  export interface SpecialFolders {
12
13
  inbox?: string;
@@ -102,8 +103,18 @@ export declare class CompatImapClient {
102
103
  createmailbox(name: string): Promise<void>;
103
104
  /** Append a message to a mailbox */
104
105
  appendMessage(mailbox: string, message: string | Uint8Array, flags?: string[]): Promise<number | null>;
105
- /** Watch a mailbox for new messages (IDLE) */
106
- watchMailbox(mailbox: string, onNew: (count: number) => void): Promise<() => Promise<void>>;
106
+ /** Cached CAPABILITY set parsed at connect/login. Callers gate
107
+ * optional features (NOTIFY, QRESYNC, MOVE, ...) on this. */
108
+ getCapabilities(): Set<string>;
109
+ /** Watch a mailbox for new messages (IDLE). Optionally engage RFC 5465
110
+ * NOTIFY so the server also pushes STATUS responses for non-selected
111
+ * mailboxes named in `opts.notifySpec` — `opts.onMailboxStatus` fires
112
+ * for each. Capability check is the caller's responsibility; pass
113
+ * notifySpec only when `getCapabilities().has("NOTIFY")`. */
114
+ watchMailbox(mailbox: string, onNew: (count: number) => void, opts?: {
115
+ notifySpec?: string;
116
+ onMailboxStatus?: (mailbox: string, data: proto.StatusData) => void;
117
+ }): Promise<() => Promise<void>>;
107
118
  /** Copy a message to another server (cross-account) */
108
119
  moveMessageToServer(msg: any, fromMailbox: string, targetClient: CompatImapClient, toMailbox: string): Promise<void>;
109
120
  /** Rename a mailbox (via native access) */
package/imap-compat.js CHANGED
@@ -274,10 +274,23 @@ export class CompatImapClient {
274
274
  const data = typeof message === "string" ? message : new TextDecoder().decode(message);
275
275
  return this.native.appendMessage(mailbox, data, flags);
276
276
  }
277
- /** Watch a mailbox for new messages (IDLE) */
278
- async watchMailbox(mailbox, onNew) {
277
+ /** Cached CAPABILITY set parsed at connect/login. Callers gate
278
+ * optional features (NOTIFY, QRESYNC, MOVE, ...) on this. */
279
+ getCapabilities() {
280
+ return this.native.getCapabilities();
281
+ }
282
+ /** Watch a mailbox for new messages (IDLE). Optionally engage RFC 5465
283
+ * NOTIFY so the server also pushes STATUS responses for non-selected
284
+ * mailboxes named in `opts.notifySpec` — `opts.onMailboxStatus` fires
285
+ * for each. Capability check is the caller's responsibility; pass
286
+ * notifySpec only when `getCapabilities().has("NOTIFY")`. */
287
+ async watchMailbox(mailbox, onNew, opts) {
279
288
  await this.ensureConnected();
280
289
  await this.native.select(mailbox);
290
+ if (opts?.onMailboxStatus)
291
+ this.native.onMailboxStatus = opts.onMailboxStatus;
292
+ if (opts?.notifySpec)
293
+ await this.native.notify(opts.notifySpec);
281
294
  return this.native.startIdle(onNew);
282
295
  }
283
296
  /** Copy a message to another server (cross-account) */
package/imap-native.d.ts CHANGED
@@ -77,6 +77,11 @@ export declare class NativeImapClient {
77
77
  private idleTag;
78
78
  private idleCallback;
79
79
  private idleRefreshTimer;
80
+ /** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
81
+ * mailboxes (the server pushes these when the client has issued NOTIFY
82
+ * SET with a PERSONAL group). Distinct from `idleCallback` which only
83
+ * fires for EXISTS on the currently-selected mailbox. */
84
+ onMailboxStatus: ((mailbox: string, data: proto.StatusData) => void) | null;
80
85
  /** Set by startIdle's stop closure so auto-suspend knows not to resume after a command finishes. */
81
86
  private idleStopped;
82
87
  private verbose;
@@ -104,6 +109,11 @@ export declare class NativeImapClient {
104
109
  private authenticate;
105
110
  private starttls;
106
111
  capability(): Promise<Set<string>>;
112
+ /** Return the cached CAPABILITY set parsed at connect/login time. Callers
113
+ * use this to gate optional code paths (NOTIFY, QRESYNC, MOVE, etc.)
114
+ * without re-issuing CAPABILITY. Returns a defensive copy so callers
115
+ * can't mutate internal state. */
116
+ getCapabilities(): Set<string>;
107
117
  private parseCapabilities;
108
118
  logout(): Promise<void>;
109
119
  select(mailbox: string): Promise<MailboxInfo>;
@@ -179,6 +189,14 @@ export declare class NativeImapClient {
179
189
  expunge(): Promise<void>;
180
190
  /** Append a message to a mailbox */
181
191
  appendMessage(mailbox: string, message: string | Uint8Array, flags?: string[]): Promise<number | null>;
192
+ /** Issue NOTIFY SET so the server starts pushing unsolicited STATUS
193
+ * responses for the mailboxes named in `spec` (beyond the currently
194
+ * selected one). Capability gated — callers must check
195
+ * `getCapabilities().has("NOTIFY")` before calling. Set
196
+ * `onMailboxStatus` before issuing if you want to react. Issue AFTER
197
+ * SELECT and BEFORE startIdle — the server holds the spec for the
198
+ * lifetime of the connection. */
199
+ notify(spec: string): Promise<void>;
182
200
  startIdle(onNewMail: (count: number) => void): Promise<() => Promise<void>>;
183
201
  /**
184
202
  * If IDLE is currently active, send DONE and wait for its tagged OK so the
package/imap-native.js CHANGED
@@ -40,6 +40,11 @@ export class NativeImapClient {
40
40
  idleTag = null;
41
41
  idleCallback = null;
42
42
  idleRefreshTimer = null;
43
+ /** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
44
+ * mailboxes (the server pushes these when the client has issued NOTIFY
45
+ * SET with a PERSONAL group). Distinct from `idleCallback` which only
46
+ * fires for EXISTS on the currently-selected mailbox. */
47
+ onMailboxStatus = null;
43
48
  /** Set by startIdle's stop closure so auto-suspend knows not to resume after a command finishes. */
44
49
  idleStopped = true;
45
50
  verbose;
@@ -224,6 +229,13 @@ export class NativeImapClient {
224
229
  }
225
230
  return this.capabilities;
226
231
  }
232
+ /** Return the cached CAPABILITY set parsed at connect/login time. Callers
233
+ * use this to gate optional code paths (NOTIFY, QRESYNC, MOVE, etc.)
234
+ * without re-issuing CAPABILITY. Returns a defensive copy so callers
235
+ * can't mutate internal state. */
236
+ getCapabilities() {
237
+ return new Set(this.capabilities);
238
+ }
227
239
  parseCapabilities(text) {
228
240
  const caps = text.replace(/^CAPABILITY\s*/i, "").split(/\s+/);
229
241
  this.capabilities.clear();
@@ -623,6 +635,22 @@ export class NativeImapClient {
623
635
  const uidMatch = tagged.text.match(/APPENDUID\s+\d+\s+(\d+)/i);
624
636
  return uidMatch ? parseInt(uidMatch[1]) : null;
625
637
  }
638
+ // ── NOTIFY (RFC 5465) ──
639
+ /** Issue NOTIFY SET so the server starts pushing unsolicited STATUS
640
+ * responses for the mailboxes named in `spec` (beyond the currently
641
+ * selected one). Capability gated — callers must check
642
+ * `getCapabilities().has("NOTIFY")` before calling. Set
643
+ * `onMailboxStatus` before issuing if you want to react. Issue AFTER
644
+ * SELECT and BEFORE startIdle — the server holds the spec for the
645
+ * lifetime of the connection. */
646
+ async notify(spec) {
647
+ const tag = proto.nextTag();
648
+ const responses = await this.sendCommand(tag, proto.notifyCommand(tag, spec));
649
+ const tagged = responses.find(r => r.tag === tag);
650
+ if (!tagged || tagged.type !== "OK") {
651
+ throw new Error(`NOTIFY SET failed: ${tagged?.text || "unknown"}`);
652
+ }
653
+ }
626
654
  // ── IDLE ──
627
655
  async startIdle(onNewMail) {
628
656
  this.idleCallback = onNewMail;
@@ -1156,6 +1184,26 @@ export class NativeImapClient {
1156
1184
  }
1157
1185
  continue;
1158
1186
  }
1187
+ // RFC 5465 NOTIFY: unsolicited STATUS responses for non-selected
1188
+ // mailboxes arrive when the server has accepted a NOTIFY SET that
1189
+ // included a PERSONAL (or other) event group. Only route to the
1190
+ // callback when IDLE is active — a STATUS response outside IDLE
1191
+ // is the result of a direct STATUS command and belongs to the
1192
+ // pending command's response set (the fall-through below).
1193
+ if (this.idleTag && resp.tag === "*" && resp.type === "STATUS" && this.onMailboxStatus) {
1194
+ const parsed = proto.parseStatusResponseFull(resp.text);
1195
+ if (parsed) {
1196
+ try {
1197
+ this.onMailboxStatus(parsed.mailbox, parsed.data);
1198
+ }
1199
+ catch (err) {
1200
+ if (this.verbose)
1201
+ console.error(` [imap] onMailboxStatus threw: ${err.message}`);
1202
+ }
1203
+ continue;
1204
+ }
1205
+ // Parse failed — fall through so the response isn't silently dropped.
1206
+ }
1159
1207
  // Collect untagged responses for the pending command
1160
1208
  if (resp.tag === "*" && this.pendingCommand) {
1161
1209
  this.pendingCommand.responses.push(resp);
@@ -95,6 +95,13 @@ export declare function moveCommand(tag: string, uid: number, destination: strin
95
95
  export declare function appendCommand(tag: string, mailbox: string, flags: string[], size: number): string;
96
96
  /** Build IDLE command */
97
97
  export declare function idleCommand(tag: string): string;
98
+ /** Build NOTIFY SET command (RFC 5465). `spec` is the full event group list,
99
+ * e.g. "(SELECTED (MessageNew MessageExpunge FlagChange)) (PERSONAL
100
+ * (MessageNew MessageExpunge FlagChange MailboxName))" — the server then
101
+ * pushes unsolicited STATUS responses for non-selected mailboxes in the
102
+ * spec while the connection is idle. Capability gated: only issue when
103
+ * CAPABILITY response contains NOTIFY. */
104
+ export declare function notifyCommand(tag: string, spec: string): string;
98
105
  /** Build DONE command (ends IDLE) */
99
106
  export declare function doneCommand(): string;
100
107
  /** Build STARTTLS command */
@@ -117,6 +124,14 @@ export declare function parseResponseLine(line: string): ImapResponse;
117
124
  export declare function parseListResponse(text: string): ListData | null;
118
125
  /** Parse STATUS response: * STATUS "mailbox" (MESSAGES n UIDNEXT n ...) */
119
126
  export declare function parseStatusResponse(text: string): StatusData | null;
127
+ /** Parse a STATUS response keeping the mailbox name. Used by NOTIFY's
128
+ * unsolicited STATUS pushes where the mailbox identifies which folder
129
+ * changed — `parseStatusResponse` discards that. Mailbox may be quoted
130
+ * ("Sent") or atom-shaped (Sent); the regex handles both. */
131
+ export declare function parseStatusResponseFull(text: string): {
132
+ mailbox: string;
133
+ data: StatusData;
134
+ } | null;
120
135
  /** Parse UID SEARCH response: * SEARCH 1 2 3 4 5 */
121
136
  export declare function parseSearchResponse(text: string): number[];
122
137
  /** Parse FLAGS from a FETCH or SELECT response */
package/imap-protocol.js CHANGED
@@ -79,6 +79,15 @@ export function appendCommand(tag, mailbox, flags, size) {
79
79
  export function idleCommand(tag) {
80
80
  return buildCommand(tag, "IDLE");
81
81
  }
82
+ /** Build NOTIFY SET command (RFC 5465). `spec` is the full event group list,
83
+ * e.g. "(SELECTED (MessageNew MessageExpunge FlagChange)) (PERSONAL
84
+ * (MessageNew MessageExpunge FlagChange MailboxName))" — the server then
85
+ * pushes unsolicited STATUS responses for non-selected mailboxes in the
86
+ * spec while the connection is idle. Capability gated: only issue when
87
+ * CAPABILITY response contains NOTIFY. */
88
+ export function notifyCommand(tag, spec) {
89
+ return buildCommand(tag, `NOTIFY SET ${spec}`);
90
+ }
82
91
  /** Build DONE command (ends IDLE) */
83
92
  export function doneCommand() {
84
93
  return "DONE\r\n";
@@ -182,6 +191,34 @@ export function parseStatusResponse(text) {
182
191
  }
183
192
  return data;
184
193
  }
194
+ /** Parse a STATUS response keeping the mailbox name. Used by NOTIFY's
195
+ * unsolicited STATUS pushes where the mailbox identifies which folder
196
+ * changed — `parseStatusResponse` discards that. Mailbox may be quoted
197
+ * ("Sent") or atom-shaped (Sent); the regex handles both. */
198
+ export function parseStatusResponseFull(text) {
199
+ // Match: "quoted name" (data) OR atomname (data)
200
+ const m = text.match(/^"((?:[^"\\]|\\.)*)"\s*\((.*)\)\s*$/) || text.match(/^(\S+)\s*\((.*)\)\s*$/);
201
+ if (!m)
202
+ return null;
203
+ const mailbox = m[1].replace(/\\(.)/g, "$1");
204
+ const data = {};
205
+ const pairs = m[2].split(/\s+/);
206
+ for (let i = 0; i < pairs.length - 1; i += 2) {
207
+ const key = pairs[i].toUpperCase();
208
+ const val = parseInt(pairs[i + 1]);
209
+ if (key === "MESSAGES")
210
+ data.messages = val;
211
+ else if (key === "RECENT")
212
+ data.recent = val;
213
+ else if (key === "UIDNEXT")
214
+ data.uidNext = val;
215
+ else if (key === "UIDVALIDITY")
216
+ data.uidValidity = val;
217
+ else if (key === "UNSEEN")
218
+ data.unseen = val;
219
+ }
220
+ return { mailbox, data };
221
+ }
185
222
  /** Parse UID SEARCH response: * SEARCH 1 2 3 4 5 */
186
223
  export function parseSearchResponse(text) {
187
224
  if (!text.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",