@yozz.app/imap 0.1.2 → 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,54 +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[]>>;
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[]>>;
249
+ readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
250
+ readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
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>>;
253
+ readonly expunge: () => Promise<ImapResult<void>>;
254
+ /** RFC 4315. Refuses without UIDPLUS rather than falling back to EXPUNGE, which erases more than asked. */
255
+ readonly uidExpunge: (uidSet: string) => Promise<ImapResult<void>>;
256
+ readonly uidSearchHeader: (header: string, value: string) => Promise<ImapResult<readonly number[]>>;
257
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.
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.
261
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. */
264
- readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
265
- readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>; /** APPEND a whole RFC 5322 message to a mailbox, e.g. a Sent copy after SMTP accepted it. */
266
- readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[]) => Promise<ImapResult<void>>; /** RFC 6851 UID MOVE. Refuses without the MOVE capability (no COPY+EXPUNGE fallback). */
267
- 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>>;
268
265
  readonly create: (mailbox: string) => Promise<ImapResult<void>>;
269
266
  readonly noop: () => Promise<ImapResult<void>>;
270
- /**
271
- * RFC 2177 IDLE. Occupies the command queue until `done()`: every other command waits
272
- * behind it, so the caller MUST call `done()` before awaiting anything else. Untagged
273
- * responses that arrive while idling (EXISTS, EXPUNGE, FETCH) go to `onUntagged`.
274
- * Resolves once the server's tagged completion of the IDLE arrives after DONE.
275
- */
267
+ /** RFC 2177. Occupies the command queue until `done()`; untagged responses meanwhile go to `onUntagged`. */
276
268
  readonly idle: () => ImapIdle;
277
269
  readonly logout: () => Promise<ImapResult<void>>;
278
270
  };
279
- declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOptions) => ImapClient;
271
+ /** RFC 4315 `[APPENDUID <uidvalidity> <uid>]`. */
272
+ type AppendUid = {
273
+ readonly uidValidity: number;
274
+ readonly uid: number;
275
+ };
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;
280
291
  //#endregion
281
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;
282
301
  /**
283
- * RFC 2047 MIME Part Three: Message Header Extensions for Non-ASCII Text.
284
- *
285
- * Decodes encoded-words in header fields (Q and B encodings).
286
- * Crucial invariants:
287
- * 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
288
- * raw decoded bytes before charset decoding.
289
- * 2. The output is NEVER re-scanned.
290
- * 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.
291
304
  */
292
- declare const decodeRfc2047: (header: string) => string;
305
+ export declare const uidSets: (uids: Iterable<number>, maxLength?: number) => readonly string[];
293
306
  //#endregion
294
- 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,32 +209,67 @@ 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`) }] };
228
+ };
229
+ const MONTHS = [
230
+ "Jan",
231
+ "Feb",
232
+ "Mar",
233
+ "Apr",
234
+ "May",
235
+ "Jun",
236
+ "Jul",
237
+ "Aug",
238
+ "Sep",
239
+ "Oct",
240
+ "Nov",
241
+ "Dec"
242
+ ];
243
+ const two = (value) => String(value).padStart(2, "0");
244
+ /** RFC 9051 date-time. The day is space-padded, which the grammar requires and several servers enforce. */
245
+ const formatImapDateTime = (date) => {
246
+ const offset = -date.getTimezoneOffset();
247
+ const zone = `${offset < 0 ? "-" : "+"}${two(Math.floor(Math.abs(offset) / 60))}${two(Math.abs(offset) % 60)}`;
248
+ return `"${String(date.getDate()).padStart(2, " ")}-${MONTHS[date.getMonth()]}-${date.getFullYear()} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${zone}"`;
236
249
  };
237
- /** APPEND with the message as a literal; flags are IMAP atoms such as `\\Seen`, sent unquoted. */
238
- const buildAppendCommand = (tag, mailbox, flags, message) => {
250
+ const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
251
+ const mboxUtf7 = encodeModifiedUtf7(mailbox);
252
+ const stamp = internalDate === void 0 ? "" : `${formatImapDateTime(internalDate)} `;
239
253
  return { lines: [{
240
- text: stringToBytes(`${tag} APPEND ${quoteString(encodeModifiedUtf7(mailbox))} (${flags.join(" ")}) `),
254
+ text: stringToBytes(`${tag} APPEND ${quoteString(mboxUtf7)} (${flags.join(" ")}) ${stamp}`),
241
255
  literal: message
242
256
  }, { text: stringToBytes("\r\n") }] };
243
257
  };
244
- /** RFC 6851 UID MOVE — relocates messages into another mailbox in one round trip. */
258
+ const buildExpungeCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} EXPUNGE\r\n`) }] });
259
+ /** RFC 4315. */
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`) }] });
263
+ /**
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.
266
+ */
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. */
245
272
  const buildMoveCommand = (tag, uidSet, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} UID MOVE ${uidSet} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
246
- /** CREATE a mailbox (e.g. Archive the first time the client needs one). */
247
273
  const buildCreateCommand = (tag, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} CREATE ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
248
274
  const buildNoopCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} NOOP\r\n`) }] });
249
275
  const buildIdleCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} IDLE\r\n`) }] });
@@ -252,16 +278,6 @@ const buildIdleDoneLine = () => stringToBytes("DONE\r\n");
252
278
  const buildLogoutCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} LOGOUT\r\n`) }] });
253
279
  //#endregion
254
280
  //#region src/rfc2047.ts
255
- /**
256
- * RFC 2047 MIME Part Three: Message Header Extensions for Non-ASCII Text.
257
- *
258
- * Decodes encoded-words in header fields (Q and B encodings).
259
- * Crucial invariants:
260
- * 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
261
- * raw decoded bytes before charset decoding.
262
- * 2. The output is NEVER re-scanned.
263
- * 3. Unknown charsets leave the raw word intact.
264
- */
265
281
  const ENCODED_WORD_REGEX = /=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g;
266
282
  const decodeQToBytes = (text) => {
267
283
  const bytes = [];
@@ -373,7 +389,8 @@ const decodeRfc2047 = (header) => {
373
389
  }
374
390
  if (hasDecodeError) for (const w of sameCharsetWords) result += w.raw;
375
391
  else {
376
- const decodedText = decodeBytesWithCharset(concatByteArrays(byteChunks), firstInCharset.charset);
392
+ const mergedBytes = concatByteArrays(byteChunks);
393
+ const decodedText = decodeBytesWithCharset(mergedBytes, firstInCharset.charset);
377
394
  if (decodedText !== null) result += decodedText;
378
395
  else for (const w of sameCharsetWords) result += w.raw;
379
396
  }
@@ -389,9 +406,6 @@ const decodeRfc2047 = (header) => {
389
406
  };
390
407
  //#endregion
391
408
  //#region src/envelope.ts
392
- /**
393
- * Parsers for IMAP FETCH items: ENVELOPE, FLAGS, INTERNALDATE, RFC822.SIZE, BODYSTRUCTURE.
394
- */
395
409
  const tokenToString$1 = (token) => {
396
410
  if (token === void 0 || token.kind === "nil") return null;
397
411
  if (token.kind === "quoted" || token.kind === "atom") return token.value;
@@ -399,9 +413,7 @@ const tokenToString$1 = (token) => {
399
413
  if (token.kind === "literal") return asciiToString(token.value);
400
414
  return null;
401
415
  };
402
- /**
403
- * Parses a single address tuple `(name adl mailbox host)` and advances index past `)`.
404
- */
416
+ /** RFC 9051 address: `(name adl mailbox host)`. */
405
417
  const parseAddressTuple = (tokens, start) => {
406
418
  let idx = start;
407
419
  if (tokens[idx]?.kind !== "lparen") return null;
@@ -437,10 +449,6 @@ const parseAddressTuple = (tokens, start) => {
437
449
  nextIndex: idx
438
450
  };
439
451
  };
440
- /**
441
- * Parses an address list `( (addr1) (addr2) ... )` or `NIL`.
442
- * Flattening groups to their member addresses.
443
- */
444
452
  const parseAddressList = (tokens, start) => {
445
453
  const first = tokens[start];
446
454
  if (first === void 0 || first.kind === "nil") return {
@@ -473,10 +481,7 @@ const parseAddressList = (tokens, start) => {
473
481
  nextIndex: idx
474
482
  };
475
483
  };
476
- /**
477
- * Parses an ENVELOPE structure:
478
- * `(date subject from sender reply-to to cc bcc in-reply-to message-id)`
479
- */
484
+ /** RFC 9051 envelope: `(date subject from sender reply-to to cc bcc in-reply-to message-id)`. */
480
485
  const parseEnvelope = (tokens, start) => {
481
486
  if (tokens[start]?.kind !== "lparen") return null;
482
487
  let idx = start + 1;
@@ -526,9 +531,7 @@ const parseEnvelope = (tokens, start) => {
526
531
  nextIndex: idx
527
532
  };
528
533
  };
529
- /**
530
- * Parses BODYSTRUCTURE, extracting only the list of MIME parts (e.g. ['TEXT/PLAIN', 'TEXT/HTML']).
531
- */
534
+ /** Only the MIME types, e.g. `['TEXT/PLAIN', 'TEXT/HTML']`. */
532
535
  const parseBodyStructureParts = (tokens, start) => {
533
536
  if (tokens[start]?.kind !== "lparen") return {
534
537
  parts: [],
@@ -569,9 +572,6 @@ const parseBodyStructureParts = (tokens, start) => {
569
572
  nextIndex: idx
570
573
  };
571
574
  };
572
- /**
573
- * Parses items inside a FETCH response `(...)`.
574
- */
575
575
  const parseFetchItems = (tokens, start) => {
576
576
  if (tokens[start]?.kind !== "lparen") return {
577
577
  items: [],
@@ -611,21 +611,22 @@ const parseFetchItems = (tokens, start) => {
611
611
  kind: "internalDate",
612
612
  date: dateStr
613
613
  });
614
- } else if (itemName === "FLAGS") if (tokens[idx]?.kind === "lparen") {
615
- idx++;
616
- const flags = [];
617
- while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
618
- const flagTok = tokens[idx];
619
- if (flagTok?.kind === "atom") flags.push(flagTok.value);
614
+ } else if (itemName === "FLAGS") {
615
+ if (tokens[idx]?.kind === "lparen") {
620
616
  idx++;
621
- }
622
- if (tokens[idx]?.kind === "rparen") idx++;
623
- items.push({
624
- kind: "flags",
625
- flags
626
- });
627
- } else idx++;
628
- 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") {
629
630
  const envResult = parseEnvelope(tokens, idx);
630
631
  if (envResult !== null) {
631
632
  items.push({
@@ -634,45 +635,46 @@ const parseFetchItems = (tokens, start) => {
634
635
  });
635
636
  idx = envResult.nextIndex;
636
637
  } else idx++;
637
- } else if (itemName === "BODYSTRUCTURE" || itemName === "BODY" || itemName === "BODY.PEEK") if (tokens[idx]?.kind === "lparen") {
638
- const bsResult = parseBodyStructureParts(tokens, idx);
639
- items.push({
640
- kind: "bodyStructure",
641
- parts: bsResult.parts
642
- });
643
- idx = bsResult.nextIndex;
644
- } else if (tokens[idx]?.kind === "lbracket") {
645
- idx++;
646
- const sectionParts = [];
647
- while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") {
648
- const secTok = tokens[idx];
649
- if (secTok !== void 0) {
650
- if (secTok.kind === "atom" || secTok.kind === "quoted") sectionParts.push(secTok.value);
651
- else if (secTok.kind === "number") sectionParts.push(String(secTok.value));
652
- else if (secTok.kind === "lparen") sectionParts.push("(");
653
- else if (secTok.kind === "rparen") sectionParts.push(")");
654
- else if (secTok.kind === "nil") sectionParts.push("NIL");
655
- 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++;
656
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];
657
666
  idx++;
658
- }
659
- if (tokens[idx]?.kind === "rbracket") idx++;
660
- const section = sectionParts.join(" ");
661
- const originTok = tokens[idx];
662
- if (originTok?.kind === "atom" && /^<\d+(\.\d+)?>$/.test(originTok.value)) idx++;
663
- const bodyTok = tokens[idx];
664
- idx++;
665
- let bytes = null;
666
- if (bodyTok?.kind === "literal") bytes = bodyTok.value;
667
- else if (bodyTok?.kind === "quoted" || bodyTok?.kind === "atom") bytes = stringToBytes(bodyTok.value);
668
- else if (bodyTok?.kind === "nil") bytes = null;
669
- items.push({
670
- kind: "body",
671
- section,
672
- bytes
673
- });
674
- } else idx++;
675
- 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") {
676
678
  const id = tokenToString$1(tokens[idx]);
677
679
  idx++;
678
680
  if (id !== null && /^\d+$/.test(id)) items.push({
@@ -695,9 +697,6 @@ const parseFetchItems = (tokens, start) => {
695
697
  };
696
698
  //#endregion
697
699
  //#region src/response.ts
698
- /**
699
- * IMAP response parser: converts token stream into typed tagged, untagged, and continuation responses.
700
- */
701
700
  const tokenToString = (token) => {
702
701
  if (token === void 0 || token.kind === "nil") return null;
703
702
  if (token.kind === "quoted" || token.kind === "atom") return token.value;
@@ -945,6 +944,7 @@ const parseResponse = (tokens) => {
945
944
  }
946
945
  const delimiter = tokenToString(tokens[idx]);
947
946
  idx++;
947
+ const rawName = tokenToString(tokens[idx]) ?? "";
948
948
  return {
949
949
  ok: true,
950
950
  value: {
@@ -952,7 +952,7 @@ const parseResponse = (tokens) => {
952
952
  untagged: {
953
953
  kind: "list",
954
954
  mailbox: {
955
- name: decodeModifiedUtf7(tokenToString(tokens[idx]) ?? ""),
955
+ name: decodeModifiedUtf7(rawName),
956
956
  delimiter,
957
957
  attributes
958
958
  }
@@ -1083,22 +1083,8 @@ const parseResponse = (tokens) => {
1083
1083
  };
1084
1084
  //#endregion
1085
1085
  //#region src/tokenizer.ts
1086
- /**
1087
- * Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
1088
- *
1089
- * Implements total parsing over attacker-controlled bytes:
1090
- * - Line ends at CRLF; bare LF is a protocol failure.
1091
- * - Logical line frames `{n}` literals (and `{n+}` LITERAL+).
1092
- * - Enforces maxLiteralBytes (default 32 MiB).
1093
- * - A number above 2^32 - 1 is an atom of digits, not a failure: RFC 9051's `number` is
1094
- * 32-bit, but Gmail's X-GM-THRID is 64-bit and arrives as a bare number.
1095
- * - Never throws on malformed input; returns typed ImapResult.
1096
- */
1097
- const DEFAULT_MAX_LITERAL_BYTES = 32 * 1024 * 1024;
1086
+ const DEFAULT_MAX_LITERAL_BYTES = 33554432;
1098
1087
  const MAX_UINT32 = 4294967295;
1099
- /**
1100
- * Checks for bare LF in a slice of bytes.
1101
- */
1102
1088
  const checkBareLf = (bytes, start, end) => {
1103
1089
  for (let i = start; i < end; i++) if (bytes[i] === 10 && (i === 0 || bytes[i - 1] !== 13)) return {
1104
1090
  kind: "protocol",
@@ -1106,10 +1092,6 @@ const checkBareLf = (bytes, start, end) => {
1106
1092
  };
1107
1093
  return null;
1108
1094
  };
1109
- /**
1110
- * Parses literal length from `{<digits>}` or `{<digits>+}` before CRLF.
1111
- * Returns valid length, invalid failure, or none if not a literal header.
1112
- */
1113
1095
  const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
1114
1096
  if (crlfIndex === 0) return { kind: "none" };
1115
1097
  let i = crlfIndex - 1;
@@ -1147,9 +1129,6 @@ const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
1147
1129
  braceIndex
1148
1130
  };
1149
1131
  };
1150
- /**
1151
- * Scans `buffer` for a complete logical line (including any literals).
1152
- */
1153
1132
  const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, resume) => {
1154
1133
  const segments = resume?.segments ? [...resume.segments] : [];
1155
1134
  let scanOffset = resume?.resumeAt ?? 0;
@@ -1257,9 +1236,6 @@ const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, re
1257
1236
  textStart
1258
1237
  };
1259
1238
  };
1260
- /**
1261
- * Tokenizes a text segment into IMAP tokens.
1262
- */
1263
1239
  const tokenizeTextSegment = (bytes, tokens) => {
1264
1240
  let i = 0;
1265
1241
  const len = bytes.length;
@@ -1371,9 +1347,6 @@ const tokenizeTextSegment = (bytes, tokens) => {
1371
1347
  value: void 0
1372
1348
  };
1373
1349
  };
1374
- /**
1375
- * Tokenizes a complete LogicalLine into ImapTokens.
1376
- */
1377
1350
  const tokenizeLogicalLine = (line) => {
1378
1351
  const tokens = [];
1379
1352
  for (const seg of line.segments) if (seg.kind === "literal") tokens.push({
@@ -1391,30 +1364,98 @@ const tokenizeLogicalLine = (line) => {
1391
1364
  };
1392
1365
  //#endregion
1393
1366
  //#region src/client.ts
1367
+ const appendUidOf = (tagged) => {
1368
+ const code = tagged.code;
1369
+ if (code?.kind !== "other" || code.code !== "APPENDUID") return null;
1370
+ const [uidValidity, uid] = code.args.map(Number);
1371
+ if (uidValidity === void 0 || uid === void 0) return null;
1372
+ return Number.isInteger(uidValidity) && Number.isInteger(uid) ? {
1373
+ uidValidity,
1374
+ uid
1375
+ } : null;
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
+ };
1394
1413
  /**
1395
- * IMAP client state machine over ByteDuplex.
1396
- *
1397
- * Invariants:
1398
- * - One IMAP connection object per device (no connection pool).
1399
- * - Transport is ByteDuplex from @yozz.app/tls.
1400
- * - Parsing is total: parser never throws, returns typed ImapFailure and closes.
1401
- * - Commands are serialised: one in-flight command at a time, queued in order (no pipelining in this slice).
1402
- * - Greeting is captured immediately upon creation.
1403
- */
1404
- /**
1405
- * The msg-ids out of a `HEADER.FIELDS (REFERENCES)` section: the header unfolded, then every
1406
- * `<...>` 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.
1407
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
+ };
1408
1453
  const parseReferencesHeader = (bytes) => {
1409
1454
  if (bytes === null) return [];
1410
1455
  const line = asciiToString(bytes).replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/).find((l) => /^references:/i.test(l));
1411
1456
  if (line === void 0) return [];
1412
1457
  return line.slice(line.indexOf(":") + 1).match(/<[^<>\s]+>/g) ?? [];
1413
1458
  };
1414
- /**
1415
- * The summaries in a FETCH response's untagged lines, whether the command asked by uid or by
1416
- * sequence number: `* <seq> FETCH (UID n …)` carries both either way.
1417
- */
1418
1459
  const summariesFrom = (untagged) => untagged.flatMap((item) => {
1419
1460
  if (item.kind !== "fetch") return [];
1420
1461
  let uid = 0;
@@ -1453,9 +1494,6 @@ const createImapClient = (transport, options) => {
1453
1494
  let knownCapabilities = [];
1454
1495
  let tagCounter = 1;
1455
1496
  const nextTag = () => `A${String(tagCounter++).padStart(4, "0")}`;
1456
- /**
1457
- * Reads and parses the next complete IMAP response from the transport.
1458
- */
1459
1497
  const readNextResponse = async () => {
1460
1498
  if (failureReason !== null) return {
1461
1499
  ok: false,
@@ -1522,9 +1560,6 @@ const createImapClient = (transport, options) => {
1522
1560
  totalBufferedBytes += chunk.length;
1523
1561
  }
1524
1562
  };
1525
- /**
1526
- * Greeting is captured immediately as soon as client is created.
1527
- */
1528
1563
  const greetingPromise = (async () => {
1529
1564
  const res = await readNextResponse();
1530
1565
  if (!res.ok) return res;
@@ -1572,9 +1607,6 @@ const createImapClient = (transport, options) => {
1572
1607
  reason: { kind: "closed" }
1573
1608
  }));
1574
1609
  };
1575
- /**
1576
- * Sends a command and collects untagged responses until matching tagged response.
1577
- */
1578
1610
  const executeCommand = async (build, options) => {
1579
1611
  if (isClosed) return {
1580
1612
  ok: false,
@@ -1742,21 +1774,22 @@ const createImapClient = (transport, options) => {
1742
1774
  text: resp.untagged.text
1743
1775
  };
1744
1776
  }
1745
- } else if (resp.kind === "continuation") if (options?.onContinuation !== void 0) {
1746
- const contRes = await options.onContinuation();
1747
- if (!contRes.ok) return contRes;
1748
- } else {
1749
- isClosed = true;
1750
- failureReason = {
1751
- kind: "protocol",
1752
- detail: "Unexpected continuation response from server"
1753
- };
1754
- return {
1755
- ok: false,
1756
- reason: failureReason
1757
- };
1758
- }
1759
- 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") {
1760
1793
  if (resp.tag !== tag) {
1761
1794
  isClosed = true;
1762
1795
  failureReason = {
@@ -1820,25 +1853,26 @@ const createImapClient = (transport, options) => {
1820
1853
  const hasSaslIr = knownCapabilities.some((c) => c.toUpperCase() === "SASL-IR");
1821
1854
  const hasLoginDisabled = knownCapabilities.some((c) => c.toUpperCase() === "LOGINDISABLED");
1822
1855
  let authCmdRes;
1823
- if (hasAuthPlain) if (hasSaslIr) authCmdRes = await executeCommand((tag) => buildAuthenticatePlainSaslIrCommand(tag, username, password));
1824
- else authCmdRes = await executeCommand((tag) => buildAuthenticatePlainInitialCommand(tag), { onContinuation: async () => {
1825
- try {
1826
- const respCmd = buildAuthenticatePlainResponse(username, password);
1827
- for (const line of respCmd.lines) await transport.write(line.text);
1828
- return {
1829
- ok: true,
1830
- value: void 0
1831
- };
1832
- } catch {
1833
- isClosed = true;
1834
- failureReason = { kind: "closed" };
1835
- return {
1836
- ok: false,
1837
- reason: { kind: "closed" }
1838
- };
1839
- }
1840
- } });
1841
- 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));
1842
1876
  else return {
1843
1877
  ok: false,
1844
1878
  reason: {
@@ -1967,14 +2001,63 @@ const createImapClient = (transport, options) => {
1967
2001
  value: void 0
1968
2002
  };
1969
2003
  }),
1970
- append: (mailbox, message, flags) => enqueueCommand(async () => {
1971
- const res = await executeCommand((tag) => buildAppendCommand(tag, mailbox, flags, message));
2004
+ append: (mailbox, message, flags, internalDate) => enqueueCommand(async () => {
2005
+ const res = await executeCommand((tag) => buildAppendCommand(tag, mailbox, flags, message, internalDate));
2006
+ if (!res.ok) return res;
2007
+ return {
2008
+ ok: true,
2009
+ value: appendUidOf(res.value.tagged)
2010
+ };
2011
+ }),
2012
+ expunge: () => enqueueCommand(async () => {
2013
+ const res = await executeCommand(buildExpungeCommand);
2014
+ if (!res.ok) return res;
2015
+ return {
2016
+ ok: true,
2017
+ value: void 0
2018
+ };
2019
+ }),
2020
+ uidExpunge: (uidSet) => enqueueCommand(async () => {
2021
+ const greetRes = await greetingPromise;
2022
+ if (!greetRes.ok) return greetRes;
2023
+ if (!knownCapabilities.some((c) => c.toUpperCase() === "UIDPLUS")) return {
2024
+ ok: false,
2025
+ reason: {
2026
+ kind: "no",
2027
+ text: "UID EXPUNGE needs UIDPLUS, which this server lacks"
2028
+ }
2029
+ };
2030
+ const res = await executeCommand((tag) => buildUidExpungeCommand(tag, uidSet));
1972
2031
  if (!res.ok) return res;
1973
2032
  return {
1974
2033
  ok: true,
1975
2034
  value: void 0
1976
2035
  };
1977
2036
  }),
2037
+ uidSearchHeader: (header, value) => enqueueCommand(async () => {
2038
+ const res = await executeCommand((tag) => buildUidSearchHeaderCommand(tag, header, value));
2039
+ if (!res.ok) return res;
2040
+ return {
2041
+ ok: true,
2042
+ value: res.value.untagged.flatMap((item) => item.kind === "search" ? [...item.uids] : [])
2043
+ };
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
+ }),
1978
2061
  move: (uidSet, mailbox) => enqueueCommand(async () => {
1979
2062
  const greetRes = await greetingPromise;
1980
2063
  if (!greetRes.ok) return greetRes;
@@ -1992,7 +2075,7 @@ const createImapClient = (transport, options) => {
1992
2075
  if (!res.ok) return res;
1993
2076
  return {
1994
2077
  ok: true,
1995
- value: void 0
2078
+ value: copyUidOf(res.value.untagged, res.value.tagged)
1996
2079
  };
1997
2080
  }),
1998
2081
  create: (mailbox) => enqueueCommand(async () => {
@@ -2024,9 +2107,8 @@ const createImapClient = (transport, options) => {
2024
2107
  }
2025
2108
  let doneRequested = false;
2026
2109
  let doneSent = false;
2027
- /** True once '+' arrived; DONE may be written (including from `done()` while a read waits). */
2110
+ /** Set once '+' arrived; only then may DONE be written. */
2028
2111
  let isIdling = false;
2029
- /** True once the IDLE has settled either way; a `done()` after that has nothing to end. */
2030
2112
  let isEnded = false;
2031
2113
  const sendDone = async () => {
2032
2114
  if (doneSent || !isIdling || isEnded) return null;
@@ -2228,4 +2310,34 @@ const createImapClient = (transport, options) => {
2228
2310
  };
2229
2311
  };
2230
2312
  //#endregion
2231
- 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,7 +1,7 @@
1
1
  {
2
2
  "name": "@yozz.app/imap",
3
- "version": "0.1.2",
4
- "description": "Transport-agnostic IMAP4rev2/rev1 client core: literals, total parsing, RFC 2047, SASL PLAIN/LOGIN, IDLE, UID MOVE.",
3
+ "version": "0.1.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",
7
7
  "repository": {
@@ -29,10 +29,11 @@
29
29
  "access": "public"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/node": "^24.0.0",
33
- "tsdown": "^0.22.3",
34
- "@yozz.app/tls": "0.1.2",
35
- "@yozz.app/x509": "0.1.2"
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"
36
37
  },
37
38
  "scripts": {
38
39
  "build": "tsdown src/index.ts --format esm --dts",