@yozz.app/imap 0.1.3 → 0.1.4

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/dist/index.d.mts CHANGED
@@ -1,16 +1,5 @@
1
1
  //#region src/tokenizer.d.ts
2
- /**
3
- * Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
4
- *
5
- * Implements total parsing over attacker-controlled bytes:
6
- * - Line ends at CRLF; bare LF is a protocol failure.
7
- * - Logical line frames `{n}` literals (and `{n+}` LITERAL+).
8
- * - Enforces maxLiteralBytes (default 32 MiB).
9
- * - A number above 2^32 - 1 is an atom of digits, not a failure: RFC 9051's `number` is
10
- * 32-bit, but Gmail's X-GM-THRID is 64-bit and arrives as a bare number.
11
- * - Never throws on malformed input; returns typed ImapResult.
12
- */
13
- declare const DEFAULT_MAX_LITERAL_BYTES: number;
2
+ export declare const DEFAULT_MAX_LITERAL_BYTES: number;
14
3
  type ImapToken = {
15
4
  readonly kind: 'atom';
16
5
  readonly value: string;
@@ -103,7 +92,9 @@ type ImapFetchItem = {
103
92
  } | {
104
93
  readonly kind: 'bodyStructure';
105
94
  readonly parts: readonly string[];
106
- } /** Gmail's `X-GM-THRID`: 64-bit, so kept as its decimal digits. */ | {
95
+ } |
96
+ /** Gmail's `X-GM-THRID` is 64-bit, so it stays decimal digits. */
97
+ {
107
98
  readonly kind: 'gmailThreadId';
108
99
  readonly id: string;
109
100
  } | {
@@ -190,14 +181,9 @@ type ImapResponse = ImapTagged | {
190
181
  readonly kind: 'untagged';
191
182
  readonly untagged: ImapUntagged;
192
183
  } | ImapContinuation;
193
- declare const parseResponse: (tokens: readonly ImapToken[]) => ImapResult<ImapResponse>;
184
+ export declare const parseResponse: (tokens: readonly ImapToken[]) => ImapResult<ImapResponse>;
194
185
  //#endregion
195
186
  //#region src/transport.d.ts
196
- /**
197
- * What this client needs from a transport: bytes in, bytes out. Declared here rather than
198
- * imported from `@yozz.app/tls` so the package has no runtime dependency at all; it is the same
199
- * two-method shape, so a `TlsConnection` satisfies it structurally.
200
- */
201
187
  type ByteDuplex = {
202
188
  readonly read: () => Promise<Uint8Array | null>;
203
189
  readonly write: (bytes: Uint8Array) => Promise<void>;
@@ -210,8 +196,10 @@ type ImapMessageSummary = {
210
196
  readonly flags: readonly string[];
211
197
  readonly internalDate: string | null;
212
198
  readonly size: number | null;
213
- readonly envelope: ImapEnvelope | null; /** The msg-ids of the `References` header, in order; empty when the message carries none. */
214
- readonly references: readonly string[]; /** Gmail's thread id (decimal digits), when the server was asked and answered. */
199
+ readonly envelope: ImapEnvelope | null;
200
+ /** The msg-ids of the `References` header, in order; empty when the message carries none. */
201
+ readonly references: readonly string[];
202
+ /** Gmail's thread id (decimal digits), when the server was asked and answered. */
215
203
  readonly gmailThreadId: string | null;
216
204
  };
217
205
  type ImapMessageFlags = {
@@ -228,11 +216,14 @@ type ImapSelected = {
228
216
  readonly readOnly: boolean;
229
217
  };
230
218
  type ImapClientOptions = {
231
- readonly onUntagged?: (response: ImapUntagged) => void; /** Max bytes of a single literal the client will buffer; default 32 MiB. Larger → protocol failure. */
219
+ readonly onUntagged?: (response: ImapUntagged) => void;
220
+ /** Default 32 MiB; a larger literal is a protocol failure. */
232
221
  readonly maxLiteralBytes?: number;
233
222
  };
234
223
  type ImapIdle = {
235
- /** Sends DONE (once; later calls are no-ops) and resolves with the IDLE's outcome. */readonly done: () => Promise<ImapResult<void>>; /** The same outcome, for a caller that wants to observe an idle ending on its own (BYE, EOF). */
224
+ /** Sends DONE (once; later calls are no-ops) and resolves with the IDLE's outcome. */
225
+ readonly done: () => Promise<ImapResult<void>>;
226
+ /** The same outcome, for a caller that wants to observe an idle ending on its own (BYE, EOF). */
236
227
  readonly ended: Promise<ImapResult<void>>;
237
228
  };
238
229
  type ImapClient = {
@@ -241,80 +232,76 @@ type ImapClient = {
241
232
  readonly capabilities: readonly string[] | null;
242
233
  }>>;
243
234
  readonly capability: () => Promise<ImapResult<readonly string[]>>;
244
- readonly capabilities: () => readonly string[]; /** Case-insensitive check against the last known capability list. */
235
+ /** Last known. */
236
+ readonly capabilities: () => readonly string[];
237
+ /** Case-insensitive check against the last known capability list. */
245
238
  readonly hasCapability: (name: string) => boolean;
246
239
  readonly authenticate: (username: string, password: string) => Promise<ImapResult<void>>;
247
240
  readonly list: (reference: string, pattern: string) => Promise<ImapResult<readonly ImapMailbox[]>>;
248
241
  readonly select: (mailbox: string, options?: {
249
242
  readonly readOnly?: boolean;
250
243
  }) => Promise<ImapResult<ImapSelected>>;
251
- /**
252
- * UID FETCH with FLAGS ENVELOPE INTERNALDATE RFC822.SIZE, the `References` header and, on a
253
- * server advertising `X-GM-EXT-1`, `X-GM-THRID`. `set` is an IMAP sequence-set string, e.g.
254
- * "1:*" or "100:200".
255
- */
244
+ /** `uidSet` is an IMAP sequence-set, e.g. "1:*" or "100:200". */
256
245
  readonly fetchSummaries: (uidSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>;
257
- /**
258
- * The same FETCH read by message SEQUENCE number rather than uid. Sequence numbers are dense
259
- * (1..EXISTS in the selected mailbox), so a fixed-width window is that many real messages —
260
- * which uids, sparse wherever mail has been deleted, cannot promise.
261
- */
262
- readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>; /** UID FETCH FLAGS only — what a resync of already-known messages asks for. */
263
- readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>; /** UID FETCH BODY.PEEK[] — the whole raw message. */
246
+ /** By message sequence number (dense, 1..EXISTS) rather than uid (sparse after deletions). */
247
+ readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>;
248
+ readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>;
264
249
  readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
265
250
  readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
266
- /**
267
- * APPEND a whole RFC 5322 message to a mailbox, e.g. a Sent copy after SMTP accepted it.
268
- * Without `internalDate` the server stamps the message with its own clock.
269
- *
270
- * Resolves with the `APPENDUID` the server issued (RFC 4315), or `null` where it issued none —
271
- * a server without UIDPLUS. That locator is how a caller addresses what it just wrote without
272
- * searching for it, which matters for a draft: the alternative is finding it by Message-ID, and
273
- * a retry after a lost response would then be indistinguishable from a duplicate.
274
- */
275
- readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[], internalDate?: Date) => Promise<ImapResult<AppendUid | null>>; /** EXPUNGE: erases every `\\Deleted` message in the selected mailbox. */
251
+ /** Resolves with the RFC 4315 `APPENDUID`, or `null` from a server without UIDPLUS. */
252
+ readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[], internalDate?: Date) => Promise<ImapResult<AppendUid | null>>;
276
253
  readonly expunge: () => Promise<ImapResult<void>>;
277
- /**
278
- * RFC 4315 UID EXPUNGE: erases only these `\\Deleted` messages, leaving anything another
279
- * client flagged alone. Refuses without UIDPLUS rather than falling back to plain EXPUNGE,
280
- * which would erase more than was asked.
281
- */
254
+ /** RFC 4315. Refuses without UIDPLUS rather than falling back to EXPUNGE, which erases more than asked. */
282
255
  readonly uidExpunge: (uidSet: string) => Promise<ImapResult<void>>;
256
+ readonly uidSearchHeader: (header: string, value: string) => Promise<ImapResult<readonly number[]>>;
283
257
  /**
284
- * UID SEARCH over one header's exact value in the selected mailbox, newest-last uid order as
285
- * the server gives it. An empty array means the mailbox does not hold it.
258
+ * The selected mailbox's uids whose subject, sender or body contains `text`, ascending. One
259
+ * search per field and a union: `TEXT` alone is the body on Forward Email's server, and its `OR`
260
+ * answers a subset of what the operands do.
286
261
  */
287
- readonly uidSearchHeader: (header: string, value: string) => Promise<ImapResult<readonly number[]>>; /** RFC 6851 UID MOVE. Refuses without the MOVE capability (no COPY+EXPUNGE fallback). */
288
- readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<void>>; /** CREATE a mailbox. */
262
+ readonly uidSearchText: (text: string) => Promise<ImapResult<readonly number[]>>;
263
+ /** RFC 6851. Answers with the RFC 4315 `COPYUID`, or null when the server gave none. Refuses without MOVE. */
264
+ readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<CopyUid | null>>;
289
265
  readonly create: (mailbox: string) => Promise<ImapResult<void>>;
290
266
  readonly noop: () => Promise<ImapResult<void>>;
291
- /**
292
- * RFC 2177 IDLE. Occupies the command queue until `done()`: every other command waits
293
- * behind it, so the caller MUST call `done()` before awaiting anything else. Untagged
294
- * responses that arrive while idling (EXISTS, EXPUNGE, FETCH) go to `onUntagged`.
295
- * Resolves once the server's tagged completion of the IDLE arrives after DONE.
296
- */
267
+ /** RFC 2177. Occupies the command queue until `done()`; untagged responses meanwhile go to `onUntagged`. */
297
268
  readonly idle: () => ImapIdle;
298
269
  readonly logout: () => Promise<ImapResult<void>>;
299
270
  };
300
- /** Where an APPEND landed: RFC 4315's `[APPENDUID <uidvalidity> <uid>]`. */
271
+ /** RFC 4315 `[APPENDUID <uidvalidity> <uid>]`. */
301
272
  type AppendUid = {
302
273
  readonly uidValidity: number;
303
274
  readonly uid: number;
304
275
  };
305
- declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOptions) => ImapClient;
276
+ /** `first` to `last` inclusive, ascending: RFC 4315 §3 reads a range `12:10` as 10, 11, 12. */
277
+ type UidRange = readonly [first: number, last: number];
278
+ /**
279
+ * RFC 4315 `COPYUID`: the n-th uid of `source` is now the n-th uid of `target`, in a destination
280
+ * mailbox whose UIDVALIDITY is `uidValidity`. Kept as ranges: a server's `1:4294967295` costs two
281
+ * numbers, never four billion. Read one uid's destination with `copiedUid`.
282
+ */
283
+ type CopyUid = {
284
+ readonly uidValidity: number;
285
+ readonly source: readonly UidRange[];
286
+ readonly target: readonly UidRange[];
287
+ };
288
+ /** Where `COPYUID` says the message at `uid` went, or null when it does not mention it. */
289
+ export declare const copiedUid: ({ source, target }: CopyUid, uid: number) => number | null;
290
+ export declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOptions) => ImapClient;
306
291
  //#endregion
307
292
  //#region src/rfc2047.d.ts
293
+ export declare const decodeRfc2047: (header: string) => string;
294
+ //#endregion
295
+ //#region src/uid-set.d.ts
296
+ /**
297
+ * RFC 7162 §4 asks a client to keep a command line to about 8192 octets. A set stops well short, so
298
+ * the tag, the command and a mailbox name fit beside it.
299
+ */
300
+ export declare const MAX_UID_SET_LENGTH = 4096;
308
301
  /**
309
- * RFC 2047 MIME Part Three: Message Header Extensions for Non-ASCII Text.
310
- *
311
- * Decodes encoded-words in header fields (Q and B encodings).
312
- * Crucial invariants:
313
- * 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
314
- * raw decoded bytes before charset decoding.
315
- * 2. The output is NEVER re-scanned.
316
- * 3. Unknown charsets leave the raw word intact.
302
+ * Uids as RFC 9051 sequence sets, runs written as ranges (`3:5,9`), split so that no set is longer
303
+ * than `maxLength`: one command per set, however many uids are written to at once.
317
304
  */
318
- declare const decodeRfc2047: (header: string) => string;
305
+ export declare const uidSets: (uids: Iterable<number>, maxLength?: number) => readonly string[];
319
306
  //#endregion
320
- export { type ByteDuplex, DEFAULT_MAX_LITERAL_BYTES, type ImapAddress, type ImapClient, type ImapClientOptions, type ImapContinuation, type ImapEnvelope, type ImapFailure, type ImapFetchItem, type ImapIdle, type ImapMailbox, type ImapMessageSummary, type ImapResponse, type ImapResponseCode, type ImapResult, type ImapSelected, type ImapTagged, type ImapToken, type ImapUntagged, createImapClient, decodeRfc2047, parseResponse };
307
+ export type { ByteDuplex, CopyUid, ImapAddress, ImapClient, ImapClientOptions, ImapContinuation, ImapEnvelope, ImapFailure, ImapFetchItem, ImapIdle, ImapMailbox, ImapMessageSummary, ImapResponse, ImapResponseCode, ImapResult, ImapSelected, ImapTagged, ImapToken, ImapUntagged };
package/dist/index.mjs CHANGED
@@ -49,14 +49,7 @@ const bytesToBase64 = (bytes) => {
49
49
  };
50
50
  //#endregion
51
51
  //#region src/utf7.ts
52
- /**
53
- * RFC 3501 §5.1.3 Modified UTF-7 encoding and decoding for IMAP mailbox names.
54
- *
55
- * Printable ASCII (0x20..0x7E) except '&' are direct.
56
- * '&' is encoded as '&-'.
57
- * Non-ASCII characters are encoded as UTF-16BE in modified Base64 (',' instead of '/', no '=' padding),
58
- * enclosed between '&' and '-'.
59
- */
52
+ /** RFC 3501 §5.1.3 modified UTF-7: UTF-16BE in base64 with `,` for `/` and no padding, between `&` and `-`. */
60
53
  const b64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,";
61
54
  const encodeModifiedBase64 = (codeUnits) => {
62
55
  const bytes = [];
@@ -156,10 +149,6 @@ const decodeModifiedUtf7 = (str) => {
156
149
  };
157
150
  //#endregion
158
151
  //#region src/commands.ts
159
- /**
160
- * Pure builders for IMAP commands. Each builder returns exact bytes to send.
161
- * No I/O is performed here.
162
- */
163
152
  const quoteString = (str) => {
164
153
  return `"${str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
165
154
  };
@@ -182,11 +171,13 @@ const buildSaslPlainBytes = (user, pass) => {
182
171
  };
183
172
  const buildCapabilityCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} CAPABILITY\r\n`) }] });
184
173
  const buildAuthenticatePlainSaslIrCommand = (tag, user, pass) => {
185
- return { lines: [{ text: stringToBytes(`${tag} AUTHENTICATE PLAIN ${bytesToBase64(buildSaslPlainBytes(user, pass))}\r\n`) }] };
174
+ const base64Auth = bytesToBase64(buildSaslPlainBytes(user, pass));
175
+ return { lines: [{ text: stringToBytes(`${tag} AUTHENTICATE PLAIN ${base64Auth}\r\n`) }] };
186
176
  };
187
177
  const buildAuthenticatePlainInitialCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} AUTHENTICATE PLAIN\r\n`) }] });
188
178
  const buildAuthenticatePlainResponse = (user, pass) => {
189
- return { lines: [{ text: stringToBytes(`${bytesToBase64(buildSaslPlainBytes(user, pass))}\r\n`) }] };
179
+ const base64Auth = bytesToBase64(buildSaslPlainBytes(user, pass));
180
+ return { lines: [{ text: stringToBytes(`${base64Auth}\r\n`) }] };
190
181
  };
191
182
  const buildLoginCommand = (tag, user, pass) => {
192
183
  const userSafe = isSafeQuotedString(user);
@@ -218,21 +209,22 @@ const buildListCommand = (tag, reference, pattern) => {
218
209
  return { lines: [{ text: stringToBytes(`${tag} LIST ${quoteString(refUtf7)} ${quoteString(patUtf7)}\r\n`) }] };
219
210
  };
220
211
  const buildSelectCommand = (tag, mailbox, readOnly = false) => {
221
- return { lines: [{ text: stringToBytes(`${tag} ${readOnly ? "EXAMINE" : "SELECT"} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] };
212
+ const verb = readOnly ? "EXAMINE" : "SELECT";
213
+ const mboxUtf7 = encodeModifiedUtf7(mailbox);
214
+ return { lines: [{ text: stringToBytes(`${tag} ${verb} ${quoteString(mboxUtf7)}\r\n`) }] };
222
215
  };
223
- /**
224
- * Everything a list row and client-side threading need, in one round trip. `References` is the
225
- * one header ENVELOPE does not carry; `X-GM-THRID` is Gmail's own threading answer and is only
226
- * asked for when the server advertised `X-GM-EXT-1`, since an unknown item is a BAD.
227
- *
228
- * `bySeq` drops the `UID` prefix, so `set` is read as message sequence numbers instead. `UID`
229
- * stays in the items either way: a summary is worthless without the id its folder is keyed by.
230
- */
216
+ /** `X-GM-THRID` is asked for only when the server advertised `X-GM-EXT-1`: an unknown item is a BAD. */
231
217
  const buildFetchSummariesCommand = (tag, set, { gmail, bySeq = false }) => ({ lines: [{ text: stringToBytes(`${tag} ${bySeq ? "FETCH" : "UID FETCH"} ${set} (FLAGS ENVELOPE INTERNALDATE RFC822.SIZE UID BODY.PEEK[HEADER.FIELDS (REFERENCES)]${gmail ? " X-GM-THRID" : ""})\r\n`) }] });
232
218
  const buildFetchFlagsCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (FLAGS UID)\r\n`) }] });
233
219
  const buildFetchRawCommand = (tag, uid) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uid} (BODY.PEEK[])\r\n`) }] });
220
+ const STORE_OPS = {
221
+ add: "+FLAGS",
222
+ remove: "-FLAGS",
223
+ set: "FLAGS"
224
+ };
234
225
  const buildStoreFlagsCommand = (tag, uidSet, mode, flags) => {
235
- return { lines: [{ text: stringToBytes(`${tag} UID STORE ${uidSet} ${mode === "add" ? "+FLAGS" : mode === "remove" ? "-FLAGS" : "FLAGS"} (${flags.join(" ")})\r\n`) }] };
226
+ const op = STORE_OPS[mode];
227
+ return { lines: [{ text: stringToBytes(`${tag} UID STORE ${uidSet} ${op} (${flags.join(" ")})\r\n`) }] };
236
228
  };
237
229
  const MONTHS = [
238
230
  "Jan",
@@ -249,17 +241,12 @@ const MONTHS = [
249
241
  "Dec"
250
242
  ];
251
243
  const two = (value) => String(value).padStart(2, "0");
252
- /**
253
- * RFC 9051 date-time, quoted: `"28-Aug-2026 09:48:00 +0800"`. The day is space-padded rather
254
- * than zero-padded, which the grammar requires and several servers enforce. Local zone, so it
255
- * says when the message reached this client.
256
- */
244
+ /** RFC 9051 date-time. The day is space-padded, which the grammar requires and several servers enforce. */
257
245
  const formatImapDateTime = (date) => {
258
246
  const offset = -date.getTimezoneOffset();
259
247
  const zone = `${offset < 0 ? "-" : "+"}${two(Math.floor(Math.abs(offset) / 60))}${two(Math.abs(offset) % 60)}`;
260
248
  return `"${String(date.getDate()).padStart(2, " ")}-${MONTHS[date.getMonth()]}-${date.getFullYear()} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${zone}"`;
261
249
  };
262
- /** APPEND with the message as a literal; flags are IMAP atoms such as `\\Seen`, sent unquoted. */
263
250
  const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
264
251
  const mboxUtf7 = encodeModifiedUtf7(mailbox);
265
252
  const stamp = internalDate === void 0 ? "" : `${formatImapDateTime(internalDate)} `;
@@ -268,32 +255,21 @@ const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
268
255
  literal: message
269
256
  }, { text: stringToBytes("\r\n") }] };
270
257
  };
271
- /** EXPUNGE: erases every `\\Deleted` message in the selected mailbox. */
272
258
  const buildExpungeCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} EXPUNGE\r\n`) }] });
273
- /**
274
- * RFC 4315 UID EXPUNGE: erases only the named `\\Deleted` messages. Plain EXPUNGE erases every
275
- * `\\Deleted` message in the mailbox, including ones another client flagged and has not yet
276
- * erased itself — so replacing a draft with it would quietly take somebody else's deletions with
277
- * it. Needs UIDPLUS; the client refuses without it rather than falling back.
278
- */
259
+ /** RFC 4315. */
279
260
  const buildUidExpungeCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID EXPUNGE ${uidSet}\r\n`) }] });
261
+ /** A server need only index the headers IMAP names; on a private `X-` header it may answer the empty list. */
262
+ const buildUidSearchHeaderCommand = (tag, header, value) => ({ lines: [{ text: stringToBytes(`${tag} UID SEARCH HEADER ${quoteString(header)} ${quoteString(value)}\r\n`) }] });
280
263
  /**
281
- * UID SEARCH for one header's exact value, e.g. `HEADER "Message-ID" "<id>"`.
282
- *
283
- * What makes an APPEND retry safe: after a lost response the client asks whether the copy it was
284
- * about to write is already there, instead of writing a second one.
285
- *
286
- * **Ask it about a header IMAP names.** A private `X-` header looks like the better question — a
287
- * Message-ID can be rewritten by a provider, a subject match is a guess — but a server need only
288
- * index the headers the protocol defines, and one that does not index yours answers the EMPTY LIST
289
- * rather than an error. That is indistinguishable from "no copy is there", so the caller writes
290
- * the second copy, or decides it has nothing to erase. Measured on Forward Email:
291
- * docs/knowledge/forwardemail-api.md.
264
+ * One `UID SEARCH <key> <text>`. A printable-ASCII query is a quoted string in the default charset;
265
+ * anything else is a UTF-8 literal, which every server must accept.
292
266
  */
293
- const buildUidSearchHeaderCommand = (tag, header, value) => ({ lines: [{ text: stringToBytes(`${tag} UID SEARCH HEADER ${quoteString(header)} ${quoteString(value)}\r\n`) }] });
294
- /** RFC 6851 UID MOVE — relocates messages into another mailbox in one round trip. */
267
+ const buildUidSearchTextCommand = (tag, key, text) => isSafeQuotedString(text) ? { lines: [{ text: stringToBytes(`${tag} UID SEARCH ${key} ${quoteString(text)}\r\n`) }] } : { lines: [{
268
+ text: stringToBytes(`${tag} UID SEARCH CHARSET UTF-8 ${key} `),
269
+ literal: stringToBytes(text)
270
+ }, { text: stringToBytes("\r\n") }] };
271
+ /** RFC 6851. */
295
272
  const buildMoveCommand = (tag, uidSet, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} UID MOVE ${uidSet} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
296
- /** CREATE a mailbox (e.g. Archive the first time the client needs one). */
297
273
  const buildCreateCommand = (tag, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} CREATE ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
298
274
  const buildNoopCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} NOOP\r\n`) }] });
299
275
  const buildIdleCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} IDLE\r\n`) }] });
@@ -302,16 +278,6 @@ const buildIdleDoneLine = () => stringToBytes("DONE\r\n");
302
278
  const buildLogoutCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} LOGOUT\r\n`) }] });
303
279
  //#endregion
304
280
  //#region src/rfc2047.ts
305
- /**
306
- * RFC 2047 MIME Part Three: Message Header Extensions for Non-ASCII Text.
307
- *
308
- * Decodes encoded-words in header fields (Q and B encodings).
309
- * Crucial invariants:
310
- * 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
311
- * raw decoded bytes before charset decoding.
312
- * 2. The output is NEVER re-scanned.
313
- * 3. Unknown charsets leave the raw word intact.
314
- */
315
281
  const ENCODED_WORD_REGEX = /=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g;
316
282
  const decodeQToBytes = (text) => {
317
283
  const bytes = [];
@@ -423,7 +389,8 @@ const decodeRfc2047 = (header) => {
423
389
  }
424
390
  if (hasDecodeError) for (const w of sameCharsetWords) result += w.raw;
425
391
  else {
426
- const decodedText = decodeBytesWithCharset(concatByteArrays(byteChunks), firstInCharset.charset);
392
+ const mergedBytes = concatByteArrays(byteChunks);
393
+ const decodedText = decodeBytesWithCharset(mergedBytes, firstInCharset.charset);
427
394
  if (decodedText !== null) result += decodedText;
428
395
  else for (const w of sameCharsetWords) result += w.raw;
429
396
  }
@@ -439,9 +406,6 @@ const decodeRfc2047 = (header) => {
439
406
  };
440
407
  //#endregion
441
408
  //#region src/envelope.ts
442
- /**
443
- * Parsers for IMAP FETCH items: ENVELOPE, FLAGS, INTERNALDATE, RFC822.SIZE, BODYSTRUCTURE.
444
- */
445
409
  const tokenToString$1 = (token) => {
446
410
  if (token === void 0 || token.kind === "nil") return null;
447
411
  if (token.kind === "quoted" || token.kind === "atom") return token.value;
@@ -449,9 +413,7 @@ const tokenToString$1 = (token) => {
449
413
  if (token.kind === "literal") return asciiToString(token.value);
450
414
  return null;
451
415
  };
452
- /**
453
- * Parses a single address tuple `(name adl mailbox host)` and advances index past `)`.
454
- */
416
+ /** RFC 9051 address: `(name adl mailbox host)`. */
455
417
  const parseAddressTuple = (tokens, start) => {
456
418
  let idx = start;
457
419
  if (tokens[idx]?.kind !== "lparen") return null;
@@ -487,10 +449,6 @@ const parseAddressTuple = (tokens, start) => {
487
449
  nextIndex: idx
488
450
  };
489
451
  };
490
- /**
491
- * Parses an address list `( (addr1) (addr2) ... )` or `NIL`.
492
- * Flattening groups to their member addresses.
493
- */
494
452
  const parseAddressList = (tokens, start) => {
495
453
  const first = tokens[start];
496
454
  if (first === void 0 || first.kind === "nil") return {
@@ -523,10 +481,7 @@ const parseAddressList = (tokens, start) => {
523
481
  nextIndex: idx
524
482
  };
525
483
  };
526
- /**
527
- * Parses an ENVELOPE structure:
528
- * `(date subject from sender reply-to to cc bcc in-reply-to message-id)`
529
- */
484
+ /** RFC 9051 envelope: `(date subject from sender reply-to to cc bcc in-reply-to message-id)`. */
530
485
  const parseEnvelope = (tokens, start) => {
531
486
  if (tokens[start]?.kind !== "lparen") return null;
532
487
  let idx = start + 1;
@@ -576,9 +531,7 @@ const parseEnvelope = (tokens, start) => {
576
531
  nextIndex: idx
577
532
  };
578
533
  };
579
- /**
580
- * Parses BODYSTRUCTURE, extracting only the list of MIME parts (e.g. ['TEXT/PLAIN', 'TEXT/HTML']).
581
- */
534
+ /** Only the MIME types, e.g. `['TEXT/PLAIN', 'TEXT/HTML']`. */
582
535
  const parseBodyStructureParts = (tokens, start) => {
583
536
  if (tokens[start]?.kind !== "lparen") return {
584
537
  parts: [],
@@ -619,9 +572,6 @@ const parseBodyStructureParts = (tokens, start) => {
619
572
  nextIndex: idx
620
573
  };
621
574
  };
622
- /**
623
- * Parses items inside a FETCH response `(...)`.
624
- */
625
575
  const parseFetchItems = (tokens, start) => {
626
576
  if (tokens[start]?.kind !== "lparen") return {
627
577
  items: [],
@@ -661,21 +611,22 @@ const parseFetchItems = (tokens, start) => {
661
611
  kind: "internalDate",
662
612
  date: dateStr
663
613
  });
664
- } else if (itemName === "FLAGS") if (tokens[idx]?.kind === "lparen") {
665
- idx++;
666
- const flags = [];
667
- while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
668
- const flagTok = tokens[idx];
669
- if (flagTok?.kind === "atom") flags.push(flagTok.value);
614
+ } else if (itemName === "FLAGS") {
615
+ if (tokens[idx]?.kind === "lparen") {
670
616
  idx++;
671
- }
672
- if (tokens[idx]?.kind === "rparen") idx++;
673
- items.push({
674
- kind: "flags",
675
- flags
676
- });
677
- } else idx++;
678
- else if (itemName === "ENVELOPE") {
617
+ const flags = [];
618
+ while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
619
+ const flagTok = tokens[idx];
620
+ if (flagTok?.kind === "atom") flags.push(flagTok.value);
621
+ idx++;
622
+ }
623
+ if (tokens[idx]?.kind === "rparen") idx++;
624
+ items.push({
625
+ kind: "flags",
626
+ flags
627
+ });
628
+ } else idx++;
629
+ } else if (itemName === "ENVELOPE") {
679
630
  const envResult = parseEnvelope(tokens, idx);
680
631
  if (envResult !== null) {
681
632
  items.push({
@@ -684,45 +635,46 @@ const parseFetchItems = (tokens, start) => {
684
635
  });
685
636
  idx = envResult.nextIndex;
686
637
  } else idx++;
687
- } else if (itemName === "BODYSTRUCTURE" || itemName === "BODY" || itemName === "BODY.PEEK") if (tokens[idx]?.kind === "lparen") {
688
- const bsResult = parseBodyStructureParts(tokens, idx);
689
- items.push({
690
- kind: "bodyStructure",
691
- parts: bsResult.parts
692
- });
693
- idx = bsResult.nextIndex;
694
- } else if (tokens[idx]?.kind === "lbracket") {
695
- idx++;
696
- const sectionParts = [];
697
- while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") {
698
- const secTok = tokens[idx];
699
- if (secTok !== void 0) {
700
- if (secTok.kind === "atom" || secTok.kind === "quoted") sectionParts.push(secTok.value);
701
- else if (secTok.kind === "number") sectionParts.push(String(secTok.value));
702
- else if (secTok.kind === "lparen") sectionParts.push("(");
703
- else if (secTok.kind === "rparen") sectionParts.push(")");
704
- else if (secTok.kind === "nil") sectionParts.push("NIL");
705
- else if (secTok.kind === "literal") sectionParts.push(asciiToString(secTok.value));
638
+ } else if (itemName === "BODYSTRUCTURE" || itemName === "BODY" || itemName === "BODY.PEEK") {
639
+ if (tokens[idx]?.kind === "lparen") {
640
+ const bsResult = parseBodyStructureParts(tokens, idx);
641
+ items.push({
642
+ kind: "bodyStructure",
643
+ parts: bsResult.parts
644
+ });
645
+ idx = bsResult.nextIndex;
646
+ } else if (tokens[idx]?.kind === "lbracket") {
647
+ idx++;
648
+ const sectionParts = [];
649
+ while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") {
650
+ const secTok = tokens[idx];
651
+ if (secTok !== void 0) {
652
+ if (secTok.kind === "atom" || secTok.kind === "quoted") sectionParts.push(secTok.value);
653
+ else if (secTok.kind === "number") sectionParts.push(String(secTok.value));
654
+ else if (secTok.kind === "lparen") sectionParts.push("(");
655
+ else if (secTok.kind === "rparen") sectionParts.push(")");
656
+ else if (secTok.kind === "nil") sectionParts.push("NIL");
657
+ else if (secTok.kind === "literal") sectionParts.push(asciiToString(secTok.value));
658
+ }
659
+ idx++;
706
660
  }
661
+ if (tokens[idx]?.kind === "rbracket") idx++;
662
+ const section = sectionParts.join(" ");
663
+ const originTok = tokens[idx];
664
+ if (originTok?.kind === "atom" && /^<\d+(\.\d+)?>$/.test(originTok.value)) idx++;
665
+ const bodyTok = tokens[idx];
707
666
  idx++;
708
- }
709
- if (tokens[idx]?.kind === "rbracket") idx++;
710
- const section = sectionParts.join(" ");
711
- const originTok = tokens[idx];
712
- if (originTok?.kind === "atom" && /^<\d+(\.\d+)?>$/.test(originTok.value)) idx++;
713
- const bodyTok = tokens[idx];
714
- idx++;
715
- let bytes = null;
716
- if (bodyTok?.kind === "literal") bytes = bodyTok.value;
717
- else if (bodyTok?.kind === "quoted" || bodyTok?.kind === "atom") bytes = stringToBytes(bodyTok.value);
718
- else if (bodyTok?.kind === "nil") bytes = null;
719
- items.push({
720
- kind: "body",
721
- section,
722
- bytes
723
- });
724
- } else idx++;
725
- else if (itemName === "X-GM-THRID") {
667
+ let bytes = null;
668
+ if (bodyTok?.kind === "literal") bytes = bodyTok.value;
669
+ else if (bodyTok?.kind === "quoted" || bodyTok?.kind === "atom") bytes = stringToBytes(bodyTok.value);
670
+ else if (bodyTok?.kind === "nil") bytes = null;
671
+ items.push({
672
+ kind: "body",
673
+ section,
674
+ bytes
675
+ });
676
+ } else idx++;
677
+ } else if (itemName === "X-GM-THRID") {
726
678
  const id = tokenToString$1(tokens[idx]);
727
679
  idx++;
728
680
  if (id !== null && /^\d+$/.test(id)) items.push({
@@ -745,9 +697,6 @@ const parseFetchItems = (tokens, start) => {
745
697
  };
746
698
  //#endregion
747
699
  //#region src/response.ts
748
- /**
749
- * IMAP response parser: converts token stream into typed tagged, untagged, and continuation responses.
750
- */
751
700
  const tokenToString = (token) => {
752
701
  if (token === void 0 || token.kind === "nil") return null;
753
702
  if (token.kind === "quoted" || token.kind === "atom") return token.value;
@@ -995,6 +944,7 @@ const parseResponse = (tokens) => {
995
944
  }
996
945
  const delimiter = tokenToString(tokens[idx]);
997
946
  idx++;
947
+ const rawName = tokenToString(tokens[idx]) ?? "";
998
948
  return {
999
949
  ok: true,
1000
950
  value: {
@@ -1002,7 +952,7 @@ const parseResponse = (tokens) => {
1002
952
  untagged: {
1003
953
  kind: "list",
1004
954
  mailbox: {
1005
- name: decodeModifiedUtf7(tokenToString(tokens[idx]) ?? ""),
955
+ name: decodeModifiedUtf7(rawName),
1006
956
  delimiter,
1007
957
  attributes
1008
958
  }
@@ -1133,22 +1083,8 @@ const parseResponse = (tokens) => {
1133
1083
  };
1134
1084
  //#endregion
1135
1085
  //#region src/tokenizer.ts
1136
- /**
1137
- * Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
1138
- *
1139
- * Implements total parsing over attacker-controlled bytes:
1140
- * - Line ends at CRLF; bare LF is a protocol failure.
1141
- * - Logical line frames `{n}` literals (and `{n+}` LITERAL+).
1142
- * - Enforces maxLiteralBytes (default 32 MiB).
1143
- * - A number above 2^32 - 1 is an atom of digits, not a failure: RFC 9051's `number` is
1144
- * 32-bit, but Gmail's X-GM-THRID is 64-bit and arrives as a bare number.
1145
- * - Never throws on malformed input; returns typed ImapResult.
1146
- */
1147
- const DEFAULT_MAX_LITERAL_BYTES = 32 * 1024 * 1024;
1086
+ const DEFAULT_MAX_LITERAL_BYTES = 33554432;
1148
1087
  const MAX_UINT32 = 4294967295;
1149
- /**
1150
- * Checks for bare LF in a slice of bytes.
1151
- */
1152
1088
  const checkBareLf = (bytes, start, end) => {
1153
1089
  for (let i = start; i < end; i++) if (bytes[i] === 10 && (i === 0 || bytes[i - 1] !== 13)) return {
1154
1090
  kind: "protocol",
@@ -1156,10 +1092,6 @@ const checkBareLf = (bytes, start, end) => {
1156
1092
  };
1157
1093
  return null;
1158
1094
  };
1159
- /**
1160
- * Parses literal length from `{<digits>}` or `{<digits>+}` before CRLF.
1161
- * Returns valid length, invalid failure, or none if not a literal header.
1162
- */
1163
1095
  const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
1164
1096
  if (crlfIndex === 0) return { kind: "none" };
1165
1097
  let i = crlfIndex - 1;
@@ -1197,9 +1129,6 @@ const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
1197
1129
  braceIndex
1198
1130
  };
1199
1131
  };
1200
- /**
1201
- * Scans `buffer` for a complete logical line (including any literals).
1202
- */
1203
1132
  const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, resume) => {
1204
1133
  const segments = resume?.segments ? [...resume.segments] : [];
1205
1134
  let scanOffset = resume?.resumeAt ?? 0;
@@ -1307,9 +1236,6 @@ const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, re
1307
1236
  textStart
1308
1237
  };
1309
1238
  };
1310
- /**
1311
- * Tokenizes a text segment into IMAP tokens.
1312
- */
1313
1239
  const tokenizeTextSegment = (bytes, tokens) => {
1314
1240
  let i = 0;
1315
1241
  const len = bytes.length;
@@ -1421,9 +1347,6 @@ const tokenizeTextSegment = (bytes, tokens) => {
1421
1347
  value: void 0
1422
1348
  };
1423
1349
  };
1424
- /**
1425
- * Tokenizes a complete LogicalLine into ImapTokens.
1426
- */
1427
1350
  const tokenizeLogicalLine = (line) => {
1428
1351
  const tokens = [];
1429
1352
  for (const seg of line.segments) if (seg.kind === "literal") tokens.push({
@@ -1441,21 +1364,6 @@ const tokenizeLogicalLine = (line) => {
1441
1364
  };
1442
1365
  //#endregion
1443
1366
  //#region src/client.ts
1444
- /**
1445
- * IMAP client state machine over ByteDuplex.
1446
- *
1447
- * Invariants:
1448
- * - One IMAP connection object per device (no connection pool).
1449
- * - Transport is ByteDuplex from @yozz.app/tls.
1450
- * - Parsing is total: parser never throws, returns typed ImapFailure and closes.
1451
- * - Commands are serialised: one in-flight command at a time, queued in order (no pipelining in this slice).
1452
- * - Greeting is captured immediately upon creation.
1453
- */
1454
- /**
1455
- * The `APPENDUID` of a tagged OK, when the server issued one. It arrives as an unrecognised
1456
- * response code, which is exactly what `other` is for — no parser change, and a server without
1457
- * UIDPLUS simply has nothing here.
1458
- */
1459
1367
  const appendUidOf = (tagged) => {
1460
1368
  const code = tagged.code;
1461
1369
  if (code?.kind !== "other" || code.code !== "APPENDUID") return null;
@@ -1466,20 +1374,88 @@ const appendUidOf = (tagged) => {
1466
1374
  uid
1467
1375
  } : null;
1468
1376
  };
1377
+ /** RFC 3501 nz-number: what a uid can be. */
1378
+ const MAX_UID = 4294967295;
1379
+ const uidOf = (text) => {
1380
+ if (!/^[1-9][0-9]*$/.test(text)) return null;
1381
+ const uid = Number(text);
1382
+ return uid <= MAX_UID ? uid : null;
1383
+ };
1384
+ /** A uid set without `*`, which COPYUID never carries; null when malformed. */
1385
+ const uidRangesOf = (text) => {
1386
+ const ranges = [];
1387
+ for (const part of text.split(",")) {
1388
+ const bounds = part.split(":").map(uidOf);
1389
+ const uids = bounds.filter((uid) => uid !== null);
1390
+ const [first, last] = uids;
1391
+ if (first === void 0 || uids.length < bounds.length || uids.length > 2) return null;
1392
+ const end = last ?? first;
1393
+ ranges.push([Math.min(first, end), Math.max(first, end)]);
1394
+ }
1395
+ return ranges;
1396
+ };
1397
+ const sizeOfRange = ([first, last]) => last - first + 1;
1398
+ const sizeOf = (ranges) => ranges.reduce((size, range) => size + sizeOfRange(range), 0);
1399
+ const copyUidOfCode = (code) => {
1400
+ if (code?.kind !== "other" || code.code !== "COPYUID") return null;
1401
+ const [validity, sourceSet, targetSet] = code.args;
1402
+ if (validity === void 0 || sourceSet === void 0 || targetSet === void 0) return null;
1403
+ const uidValidity = uidOf(validity);
1404
+ const source = uidRangesOf(sourceSet);
1405
+ const target = uidRangesOf(targetSet);
1406
+ if (uidValidity === null || source === null || target === null) return null;
1407
+ return sizeOf(source) === sizeOf(target) ? {
1408
+ uidValidity,
1409
+ source,
1410
+ target
1411
+ } : null;
1412
+ };
1469
1413
  /**
1470
- * The msg-ids out of a `HEADER.FIELDS (REFERENCES)` section: the header unfolded, then every
1471
- * `<...>` in order. A truncated or absent header is simply fewer ids.
1414
+ * RFC 6851 §4.3 has a MOVE's COPYUID in an untagged OK ahead of its EXPUNGEs (Gmail, Dovecot);
1415
+ * RFC 4315 puts a COPY's on the tagged OK, and some servers do that for MOVE too. Either, or
1416
+ * several of them, read as one answer.
1472
1417
  */
1418
+ const copyUidOf = (untagged, tagged) => {
1419
+ const answers = [...untagged.map((response) => response.kind === "status" ? response.code : null), tagged.code].flatMap((code) => copyUidOfCode(code) ?? []);
1420
+ const [first] = answers;
1421
+ if (first === void 0) return null;
1422
+ if (answers.some((answer) => answer.uidValidity !== first.uidValidity)) return null;
1423
+ return {
1424
+ uidValidity: first.uidValidity,
1425
+ source: answers.flatMap((answer) => answer.source),
1426
+ target: answers.flatMap((answer) => answer.target)
1427
+ };
1428
+ };
1429
+ /** How many uids come before `uid`, counting through `ranges` in order; null when none holds it. */
1430
+ const positionOf = (ranges, uid) => {
1431
+ let before = 0;
1432
+ for (const range of ranges) {
1433
+ const [first, last] = range;
1434
+ if (uid >= first && uid <= last) return before + uid - first;
1435
+ before += sizeOfRange(range);
1436
+ }
1437
+ return null;
1438
+ };
1439
+ /** The uid at `position`, counting through `ranges` in order; null past their end. */
1440
+ const uidAt = (ranges, position) => {
1441
+ let before = 0;
1442
+ for (const range of ranges) {
1443
+ if (position < before + sizeOfRange(range)) return range[0] + position - before;
1444
+ before += sizeOfRange(range);
1445
+ }
1446
+ return null;
1447
+ };
1448
+ /** Where `COPYUID` says the message at `uid` went, or null when it does not mention it. */
1449
+ const copiedUid = ({ source, target }, uid) => {
1450
+ const position = positionOf(source, uid);
1451
+ return position === null ? null : uidAt(target, position);
1452
+ };
1473
1453
  const parseReferencesHeader = (bytes) => {
1474
1454
  if (bytes === null) return [];
1475
1455
  const line = asciiToString(bytes).replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/).find((l) => /^references:/i.test(l));
1476
1456
  if (line === void 0) return [];
1477
1457
  return line.slice(line.indexOf(":") + 1).match(/<[^<>\s]+>/g) ?? [];
1478
1458
  };
1479
- /**
1480
- * The summaries in a FETCH response's untagged lines, whether the command asked by uid or by
1481
- * sequence number: `* <seq> FETCH (UID n …)` carries both either way.
1482
- */
1483
1459
  const summariesFrom = (untagged) => untagged.flatMap((item) => {
1484
1460
  if (item.kind !== "fetch") return [];
1485
1461
  let uid = 0;
@@ -1518,9 +1494,6 @@ const createImapClient = (transport, options) => {
1518
1494
  let knownCapabilities = [];
1519
1495
  let tagCounter = 1;
1520
1496
  const nextTag = () => `A${String(tagCounter++).padStart(4, "0")}`;
1521
- /**
1522
- * Reads and parses the next complete IMAP response from the transport.
1523
- */
1524
1497
  const readNextResponse = async () => {
1525
1498
  if (failureReason !== null) return {
1526
1499
  ok: false,
@@ -1587,9 +1560,6 @@ const createImapClient = (transport, options) => {
1587
1560
  totalBufferedBytes += chunk.length;
1588
1561
  }
1589
1562
  };
1590
- /**
1591
- * Greeting is captured immediately as soon as client is created.
1592
- */
1593
1563
  const greetingPromise = (async () => {
1594
1564
  const res = await readNextResponse();
1595
1565
  if (!res.ok) return res;
@@ -1637,9 +1607,6 @@ const createImapClient = (transport, options) => {
1637
1607
  reason: { kind: "closed" }
1638
1608
  }));
1639
1609
  };
1640
- /**
1641
- * Sends a command and collects untagged responses until matching tagged response.
1642
- */
1643
1610
  const executeCommand = async (build, options) => {
1644
1611
  if (isClosed) return {
1645
1612
  ok: false,
@@ -1807,21 +1774,22 @@ const createImapClient = (transport, options) => {
1807
1774
  text: resp.untagged.text
1808
1775
  };
1809
1776
  }
1810
- } else if (resp.kind === "continuation") if (options?.onContinuation !== void 0) {
1811
- const contRes = await options.onContinuation();
1812
- if (!contRes.ok) return contRes;
1813
- } else {
1814
- isClosed = true;
1815
- failureReason = {
1816
- kind: "protocol",
1817
- detail: "Unexpected continuation response from server"
1818
- };
1819
- return {
1820
- ok: false,
1821
- reason: failureReason
1822
- };
1823
- }
1824
- else if (resp.kind === "tagged") {
1777
+ } else if (resp.kind === "continuation") {
1778
+ if (options?.onContinuation !== void 0) {
1779
+ const contRes = await options.onContinuation();
1780
+ if (!contRes.ok) return contRes;
1781
+ } else {
1782
+ isClosed = true;
1783
+ failureReason = {
1784
+ kind: "protocol",
1785
+ detail: "Unexpected continuation response from server"
1786
+ };
1787
+ return {
1788
+ ok: false,
1789
+ reason: failureReason
1790
+ };
1791
+ }
1792
+ } else if (resp.kind === "tagged") {
1825
1793
  if (resp.tag !== tag) {
1826
1794
  isClosed = true;
1827
1795
  failureReason = {
@@ -1885,25 +1853,26 @@ const createImapClient = (transport, options) => {
1885
1853
  const hasSaslIr = knownCapabilities.some((c) => c.toUpperCase() === "SASL-IR");
1886
1854
  const hasLoginDisabled = knownCapabilities.some((c) => c.toUpperCase() === "LOGINDISABLED");
1887
1855
  let authCmdRes;
1888
- if (hasAuthPlain) if (hasSaslIr) authCmdRes = await executeCommand((tag) => buildAuthenticatePlainSaslIrCommand(tag, username, password));
1889
- else authCmdRes = await executeCommand((tag) => buildAuthenticatePlainInitialCommand(tag), { onContinuation: async () => {
1890
- try {
1891
- const respCmd = buildAuthenticatePlainResponse(username, password);
1892
- for (const line of respCmd.lines) await transport.write(line.text);
1893
- return {
1894
- ok: true,
1895
- value: void 0
1896
- };
1897
- } catch {
1898
- isClosed = true;
1899
- failureReason = { kind: "closed" };
1900
- return {
1901
- ok: false,
1902
- reason: { kind: "closed" }
1903
- };
1904
- }
1905
- } });
1906
- else if (!hasLoginDisabled) authCmdRes = await executeCommand((tag) => buildLoginCommand(tag, username, password));
1856
+ if (hasAuthPlain) {
1857
+ if (hasSaslIr) authCmdRes = await executeCommand((tag) => buildAuthenticatePlainSaslIrCommand(tag, username, password));
1858
+ else authCmdRes = await executeCommand((tag) => buildAuthenticatePlainInitialCommand(tag), { onContinuation: async () => {
1859
+ try {
1860
+ const respCmd = buildAuthenticatePlainResponse(username, password);
1861
+ for (const line of respCmd.lines) await transport.write(line.text);
1862
+ return {
1863
+ ok: true,
1864
+ value: void 0
1865
+ };
1866
+ } catch {
1867
+ isClosed = true;
1868
+ failureReason = { kind: "closed" };
1869
+ return {
1870
+ ok: false,
1871
+ reason: { kind: "closed" }
1872
+ };
1873
+ }
1874
+ } });
1875
+ } else if (!hasLoginDisabled) authCmdRes = await executeCommand((tag) => buildLoginCommand(tag, username, password));
1907
1876
  else return {
1908
1877
  ok: false,
1909
1878
  reason: {
@@ -2073,6 +2042,22 @@ const createImapClient = (transport, options) => {
2073
2042
  value: res.value.untagged.flatMap((item) => item.kind === "search" ? [...item.uids] : [])
2074
2043
  };
2075
2044
  }),
2045
+ uidSearchText: (text) => enqueueCommand(async () => {
2046
+ const found = /* @__PURE__ */ new Set();
2047
+ for (const key of [
2048
+ "SUBJECT",
2049
+ "FROM",
2050
+ "TEXT"
2051
+ ]) {
2052
+ const res = await executeCommand((tag) => buildUidSearchTextCommand(tag, key, text));
2053
+ if (!res.ok) return res;
2054
+ for (const item of res.value.untagged) if (item.kind === "search") for (const uid of item.uids) found.add(uid);
2055
+ }
2056
+ return {
2057
+ ok: true,
2058
+ value: [...found].toSorted((a, b) => a - b)
2059
+ };
2060
+ }),
2076
2061
  move: (uidSet, mailbox) => enqueueCommand(async () => {
2077
2062
  const greetRes = await greetingPromise;
2078
2063
  if (!greetRes.ok) return greetRes;
@@ -2090,7 +2075,7 @@ const createImapClient = (transport, options) => {
2090
2075
  if (!res.ok) return res;
2091
2076
  return {
2092
2077
  ok: true,
2093
- value: void 0
2078
+ value: copyUidOf(res.value.untagged, res.value.tagged)
2094
2079
  };
2095
2080
  }),
2096
2081
  create: (mailbox) => enqueueCommand(async () => {
@@ -2122,9 +2107,8 @@ const createImapClient = (transport, options) => {
2122
2107
  }
2123
2108
  let doneRequested = false;
2124
2109
  let doneSent = false;
2125
- /** True once '+' arrived; DONE may be written (including from `done()` while a read waits). */
2110
+ /** Set once '+' arrived; only then may DONE be written. */
2126
2111
  let isIdling = false;
2127
- /** True once the IDLE has settled either way; a `done()` after that has nothing to end. */
2128
2112
  let isEnded = false;
2129
2113
  const sendDone = async () => {
2130
2114
  if (doneSent || !isIdling || isEnded) return null;
@@ -2326,4 +2310,34 @@ const createImapClient = (transport, options) => {
2326
2310
  };
2327
2311
  };
2328
2312
  //#endregion
2329
- export { DEFAULT_MAX_LITERAL_BYTES, createImapClient, decodeRfc2047, parseResponse };
2313
+ //#region src/uid-set.ts
2314
+ /**
2315
+ * RFC 7162 §4 asks a client to keep a command line to about 8192 octets. A set stops well short, so
2316
+ * the tag, the command and a mailbox name fit beside it.
2317
+ */
2318
+ const MAX_UID_SET_LENGTH = 4096;
2319
+ /**
2320
+ * Uids as RFC 9051 sequence sets, runs written as ranges (`3:5,9`), split so that no set is longer
2321
+ * than `maxLength`: one command per set, however many uids are written to at once.
2322
+ */
2323
+ const uidSets = (uids, maxLength = MAX_UID_SET_LENGTH) => {
2324
+ const ranges = [];
2325
+ for (const uid of [...new Set(uids)].toSorted((a, b) => a - b)) {
2326
+ const last = ranges.at(-1);
2327
+ if (last !== void 0 && uid === last[1] + 1) last[1] = uid;
2328
+ else ranges.push([uid, uid]);
2329
+ }
2330
+ const sets = [];
2331
+ let current = "";
2332
+ for (const [first, last] of ranges) {
2333
+ const range = first === last ? String(first) : `${first}:${last}`;
2334
+ if (current !== "" && current.length + 1 + range.length > maxLength) {
2335
+ sets.push(current);
2336
+ current = "";
2337
+ }
2338
+ current = current === "" ? range : `${current},${range}`;
2339
+ }
2340
+ return current === "" ? sets : [...sets, current];
2341
+ };
2342
+ //#endregion
2343
+ export { DEFAULT_MAX_LITERAL_BYTES, MAX_UID_SET_LENGTH, copiedUid, createImapClient, decodeRfc2047, parseResponse, uidSets };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yozz.app/imap",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Transport-agnostic IMAP4rev2/rev1 client core: literals, total parsing, RFC 2047, SASL PLAIN/LOGIN, IDLE, UID MOVE, APPEND with APPENDUID, UID EXPUNGE.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fishballapp/yozz",
@@ -29,16 +29,15 @@
29
29
  "access": "public"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/node": "^24.0.0",
33
- "tsdown": "^0.22.3",
34
- "@yozz.app/smtp": "0.1.3",
35
- "@yozz.app/tls": "0.1.3",
36
- "@yozz.app/x509": "0.1.3"
32
+ "@types/node": "^24.13.4",
33
+ "@yozz.app/smtp": "0.1.4",
34
+ "@yozz.app/tls": "0.1.4",
35
+ "@yozz.app/x509": "0.1.4",
36
+ "tsdown": "^0.23.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsdown src/index.ts --format esm --dts",
40
40
  "test": "vitest run",
41
- "live": "node harness/live.ts",
42
- "seed": "node harness/seed-inbox.ts"
41
+ "live": "node harness/live.ts"
43
42
  }
44
43
  }