@yozz.app/imap 0.1.6 → 0.3.0

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/README.md CHANGED
@@ -37,6 +37,15 @@ Or for a specific host with authentication:
37
37
  YOZZ_IMAP_HOST=imap.example.com YOZZ_IMAP_USER=me@example.com YOZZ_IMAP_PASSWORD=secret pnpm -F @yozz.app/imap live imap.example.com
38
38
  ```
39
39
 
40
+ With credentials it also checks, read-only (`UID SEARCH` and `FETCH` only), that one `UID SEARCH` of several keys
41
+ answers the uids its keys answer apart, intersected: `ok` or `MISMATCH` per command. A slow server
42
+ (Forward Email takes 25 s or more a search) makes it take minutes.
43
+
44
+ **Requires a global `Temporal`.** `append` stamps a `Temporal.ZonedDateTime` (written in its own zone), and
45
+ `UID SEARCH` takes `BEFORE` / `SINCE` days as `Temporal.PlainDate`s. Node 26, Chrome and Firefox ship it; where the runtime has none (Safari, Node 24), install
46
+ [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill) and load it before this package is
47
+ imported (`import 'temporal-polyfill/global'`). The package imports no polyfill itself.
48
+
40
49
  A literal over 64 MiB (`DEFAULT_MAX_LITERAL_BYTES`, or `maxLiteralBytes` in the options) is a protocol failure.
41
50
 
42
51
  `harness/relay-load.ts` pushes large messages through the hosted yozz.app relay, up over SMTP and
package/dist/index.d.mts CHANGED
@@ -1,3 +1,32 @@
1
+ //#region src/commands.d.ts
2
+ /** One `UID SEARCH` key. */
3
+ type UidSearchCriterion =
4
+ /** `TEXT` is the body alone on some servers, and `OR` answers less than its operands on some. */
5
+ {
6
+ readonly key: 'SUBJECT' | 'FROM' | 'TO' | 'CC' | 'BCC' | 'TEXT';
7
+ readonly value: string;
8
+ } |
9
+ /** A server need only index the headers IMAP names; on a private `X-` header it may answer the empty list. */
10
+ {
11
+ readonly key: 'HEADER';
12
+ readonly header: 'Message-ID' | 'Reply-To';
13
+ readonly value: string;
14
+ } | {
15
+ readonly key: 'UNSEEN';
16
+ } | {
17
+ readonly key: 'FLAGGED';
18
+ } |
19
+ /** By INTERNALDATE's day: `SINCE` is on or after it, `BEFORE` strictly before it. */
20
+ {
21
+ readonly key: 'BEFORE';
22
+ readonly date: Temporal.PlainDate;
23
+ } | {
24
+ readonly key: 'SINCE';
25
+ readonly date: Temporal.PlainDate;
26
+ };
27
+ /** One key, or several that a message must all meet. */
28
+ type UidSearchCriteria = UidSearchCriterion | readonly UidSearchCriterion[];
29
+ //#endregion
1
30
  //#region src/tokenizer.d.ts
2
31
  export declare const DEFAULT_MAX_LITERAL_BYTES: number;
3
32
  type ImapToken = {
@@ -91,7 +120,7 @@ type ImapFetchItem = {
91
120
  readonly bytes: Uint8Array | null;
92
121
  } | {
93
122
  readonly kind: 'bodyStructure';
94
- readonly parts: readonly string[];
123
+ readonly attachmentNames: readonly string[];
95
124
  } |
96
125
  /** Gmail's `X-GM-THRID` is 64-bit, so it stays decimal digits. */
97
126
  {
@@ -201,11 +230,21 @@ type ImapMessageSummary = {
201
230
  readonly references: readonly string[];
202
231
  /** Gmail's thread id (decimal digits), when the server was asked and answered. */
203
232
  readonly gmailThreadId: string | null;
233
+ /** The file names its parts carry, from `BODYSTRUCTURE`. */
234
+ readonly attachmentNames: readonly string[];
204
235
  };
205
236
  type ImapMessageFlags = {
206
237
  readonly uid: number;
207
238
  readonly flags: readonly string[];
208
239
  };
240
+ type ImapRawMessage = {
241
+ readonly uid: number;
242
+ readonly bytes: Uint8Array;
243
+ };
244
+ type ImapMessageAttachmentNames = {
245
+ readonly uid: number;
246
+ readonly attachmentNames: readonly string[];
247
+ };
209
248
  type ImapSelected = {
210
249
  readonly name: string;
211
250
  readonly exists: number;
@@ -246,20 +285,19 @@ type ImapClient = {
246
285
  /** By message sequence number (dense, 1..EXISTS) rather than uid (sparse after deletions). */
247
286
  readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>;
248
287
  readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>;
288
+ /** Just the file names of `fetchSummaries`, for summaries read without them. Marks nothing read. */
289
+ readonly fetchAttachmentNames: (uidSet: string) => Promise<ImapResult<readonly ImapMessageAttachmentNames[]>>;
249
290
  readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
291
+ /** Many messages whole in one command; a uid the server no longer has is simply absent. */
292
+ readonly fetchRawMessages: (uidSet: string) => Promise<ImapResult<readonly ImapRawMessage[]>>;
250
293
  readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
251
294
  /** 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>>;
295
+ readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[], internalDate?: Temporal.ZonedDateTime) => Promise<ImapResult<AppendUid | null>>;
253
296
  readonly expunge: () => Promise<ImapResult<void>>;
254
297
  /** RFC 4315. Refuses without UIDPLUS rather than falling back to EXPUNGE, which erases more than asked. */
255
298
  readonly uidExpunge: (uidSet: string) => Promise<ImapResult<void>>;
256
- readonly uidSearchHeader: (header: string, value: string) => Promise<ImapResult<readonly number[]>>;
257
- /**
258
- * The selected mailbox's uids whose subject, sender, `To` or body contains `text`, ascending.
259
- * One search per field and a union: `TEXT` alone is the body on Forward Email's server, and its
260
- * `OR` answers a subset of what the operands do.
261
- */
262
- readonly uidSearchText: (text: string) => Promise<ImapResult<readonly number[]>>;
299
+ /** The selected mailbox's uids matching every one of these keys, ascending. */
300
+ readonly uidSearch: (criteria: UidSearchCriteria) => Promise<ImapResult<readonly number[]>>;
263
301
  /** RFC 6851. Answers with the RFC 4315 `COPYUID`, or null when the server gave none. Refuses without MOVE. */
264
302
  readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<CopyUid | null>>;
265
303
  readonly create: (mailbox: string) => Promise<ImapResult<void>>;
@@ -304,4 +342,4 @@ export declare const MAX_UID_SET_LENGTH = 4096;
304
342
  */
305
343
  export declare const uidSets: (uids: Iterable<number>, maxLength?: number) => readonly string[];
306
344
  //#endregion
307
- export type { ByteDuplex, CopyUid, ImapAddress, ImapClient, ImapClientOptions, ImapContinuation, ImapEnvelope, ImapFailure, ImapFetchItem, ImapIdle, ImapMailbox, ImapMessageSummary, ImapResponse, ImapResponseCode, ImapResult, ImapSelected, ImapTagged, ImapToken, ImapUntagged };
345
+ export type { ByteDuplex, CopyUid, ImapAddress, ImapClient, ImapClientOptions, ImapContinuation, ImapEnvelope, ImapFailure, ImapFetchItem, ImapIdle, ImapMailbox, ImapMessageAttachmentNames, ImapMessageSummary, ImapRawMessage, ImapResponse, ImapResponseCode, ImapResult, ImapSelected, ImapTagged, ImapToken, ImapUntagged, UidSearchCriteria, UidSearchCriterion };
package/dist/index.mjs CHANGED
@@ -214,9 +214,11 @@ const buildSelectCommand = (tag, mailbox, readOnly = false) => {
214
214
  return { lines: [{ text: stringToBytes(`${tag} ${verb} ${quoteString(mboxUtf7)}\r\n`) }] };
215
215
  };
216
216
  /** `X-GM-THRID` is asked for only when the server advertised `X-GM-EXT-1`: an unknown item is a BAD. */
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`) }] });
217
+ const buildFetchSummariesCommand = (tag, set, { gmail, bySeq = false }) => ({ lines: [{ text: stringToBytes(`${tag} ${bySeq ? "FETCH" : "UID FETCH"} ${set} (FLAGS ENVELOPE INTERNALDATE RFC822.SIZE UID BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (REFERENCES)]${gmail ? " X-GM-THRID" : ""})\r\n`) }] });
218
218
  const buildFetchFlagsCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (FLAGS UID)\r\n`) }] });
219
- const buildFetchRawCommand = (tag, uid) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uid} (BODY.PEEK[])\r\n`) }] });
219
+ const buildFetchBodyStructureCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (UID BODYSTRUCTURE)\r\n`) }] });
220
+ /** `uidSet` is one uid or a uid set: each message comes back whole, unmarked. */
221
+ const buildFetchRawCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (BODY.PEEK[])\r\n`) }] });
220
222
  const STORE_OPS = {
221
223
  add: "+FLAGS",
222
224
  remove: "-FLAGS",
@@ -241,11 +243,18 @@ const MONTHS = [
241
243
  "Dec"
242
244
  ];
243
245
  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}"`;
246
+ /** `+hhmm` in whole minutes: an offset with seconds (a zone's local mean time before 1900) would break the grammar. */
247
+ const zoneOf = ({ offsetNanoseconds }) => {
248
+ const minutes = Math.trunc(offsetNanoseconds / 6e10);
249
+ return `${minutes < 0 ? "-" : "+"}${two(Math.floor(Math.abs(minutes) / 60))}${two(Math.abs(minutes) % 60)}`;
250
+ };
251
+ /**
252
+ * RFC 9051 date-time, in the date's own zone and always in the ISO calendar, whatever the caller's.
253
+ * The day is space-padded, which the grammar requires and several servers enforce.
254
+ */
255
+ const formatImapDateTime = (zoned) => {
256
+ const date = zoned.withCalendar("iso8601");
257
+ return `"${String(date.day).padStart(2, " ")}-${MONTHS[date.month - 1]}-${date.year} ${two(date.hour)}:${two(date.minute)}:${two(date.second)} ${zoneOf(date)}"`;
249
258
  };
250
259
  const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
251
260
  const mboxUtf7 = encodeModifiedUtf7(mailbox);
@@ -258,16 +267,40 @@ const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
258
267
  const buildExpungeCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} EXPUNGE\r\n`) }] });
259
268
  /** RFC 4315. */
260
269
  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`) }] });
270
+ const searchPieceOf = (criterion) => {
271
+ if (criterion.key === "UNSEEN" || criterion.key === "FLAGGED") return { text: criterion.key };
272
+ if (criterion.key === "BEFORE" || criterion.key === "SINCE") {
273
+ const { year, month, day } = criterion.date.withCalendar("iso8601");
274
+ return { text: `${criterion.key} ${day}-${MONTHS[month - 1]}-${String(year).padStart(4, "0")}` };
275
+ }
276
+ const key = criterion.key === "HEADER" ? `HEADER ${quoteString(criterion.header)}` : criterion.key;
277
+ return isSafeQuotedString(criterion.value) ? { text: `${key} ${quoteString(criterion.value)}` } : {
278
+ text: `${key} `,
279
+ literal: stringToBytes(criterion.value)
280
+ };
281
+ };
263
282
  /**
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.
283
+ * One `UID SEARCH <key> <key>…`: a message must meet every key (RFC 9051 §6.4.4), and no keys at all
284
+ * is `ALL`. A printable-ASCII value is a quoted string in the default charset; if any value is not,
285
+ * the command says `CHARSET UTF-8` and sends that value as a literal, which every server must accept.
266
286
  */
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") }] };
287
+ const buildUidSearchCommand = (tag, criteria) => {
288
+ const pieces = [criteria].flat().map(searchPieceOf);
289
+ const isUtf8 = pieces.some(({ literal }) => literal !== void 0);
290
+ const lines = [];
291
+ let text = `${tag} UID SEARCH${isUtf8 ? " CHARSET UTF-8" : ""}${pieces.length === 0 ? " ALL" : ""}`;
292
+ for (const piece of pieces) {
293
+ text += ` ${piece.text}`;
294
+ if (piece.literal === void 0) continue;
295
+ lines.push({
296
+ text: stringToBytes(text),
297
+ literal: piece.literal
298
+ });
299
+ text = "";
300
+ }
301
+ lines.push({ text: stringToBytes(`${text}\r\n`) });
302
+ return { lines };
303
+ };
271
304
  /** RFC 6851. */
272
305
  const buildMoveCommand = (tag, uidSet, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} UID MOVE ${uidSet} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
273
306
  const buildCreateCommand = (tag, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} CREATE ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
@@ -405,6 +438,158 @@ const decodeRfc2047 = (header) => {
405
438
  return result;
406
439
  };
407
440
  //#endregion
441
+ //#region src/body-structure.ts
442
+ /** Past this a structure is not mail anyone wrote, and a name in it is not worth finding. */
443
+ const MAX_DEPTH = 32;
444
+ const MAX_NAMES = 64;
445
+ const MAX_NAME_LENGTH = 255;
446
+ const readList = (tokens, start) => {
447
+ if (tokens[start]?.kind !== "lparen") return {
448
+ list: null,
449
+ nextIndex: start + 1
450
+ };
451
+ const list = {
452
+ kind: "list",
453
+ items: []
454
+ };
455
+ const open = [list];
456
+ let idx = start + 1;
457
+ while (idx < tokens.length && open.length > 0) {
458
+ const token = tokens[idx];
459
+ idx++;
460
+ const top = open.at(-1);
461
+ if (token === void 0 || top === void 0) break;
462
+ if (token.kind === "rparen") open.pop();
463
+ else if (token.kind === "lparen") {
464
+ const child = {
465
+ kind: "list",
466
+ items: []
467
+ };
468
+ top.items.push(child);
469
+ open.push(child);
470
+ } else top.items.push(token);
471
+ }
472
+ return {
473
+ list,
474
+ nextIndex: idx
475
+ };
476
+ };
477
+ /** A server may send a name as raw bytes; they are UTF-8 unless they cannot be. */
478
+ const decodeRawName = (bytes) => {
479
+ try {
480
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
481
+ } catch {
482
+ return new TextDecoder("latin1").decode(bytes);
483
+ }
484
+ };
485
+ const textOf = (node) => {
486
+ if (node?.kind === "literal") return decodeRawName(node.value);
487
+ if (node?.kind === "quoted") return decodeRawName(Uint8Array.from(node.value, byteOf));
488
+ return node?.kind === "atom" ? node.value : null;
489
+ };
490
+ const byteOf = (character) => character.charCodeAt(0);
491
+ /** `("KEY" "value" …)` by lowercased key. */
492
+ const paramsIn = (node) => {
493
+ if (node?.kind !== "list") return /* @__PURE__ */ new Map();
494
+ return new Map(node.items.flatMap((key, index) => {
495
+ const value = textOf(node.items[index + 1]);
496
+ const name = index % 2 === 0 ? textOf(key) : null;
497
+ return name === null || value === null ? [] : [[name.toLowerCase(), value]];
498
+ }));
499
+ };
500
+ /** RFC 2231 §4 percent-encoding, as bytes; null when an escape is broken. */
501
+ const percentDecoded = (text) => {
502
+ const bytes = new TextEncoder().encode(text);
503
+ const out = [];
504
+ for (let i = 0; i < bytes.length; i++) {
505
+ const byte = bytes[i] ?? 0;
506
+ if (byte !== 37) {
507
+ out.push(byte);
508
+ continue;
509
+ }
510
+ const hex = String.fromCharCode(bytes[i + 1] ?? 0, bytes[i + 2] ?? 0);
511
+ if (!/^[0-9a-fA-F]{2}$/u.test(hex)) return null;
512
+ out.push(Number.parseInt(hex, 16));
513
+ i += 2;
514
+ }
515
+ return out;
516
+ };
517
+ const decodedIn = (bytes, charset) => {
518
+ try {
519
+ return new TextDecoder(charset === "" ? "utf-8" : charset, { fatal: true }).decode(Uint8Array.from(bytes));
520
+ } catch {
521
+ return null;
522
+ }
523
+ };
524
+ /**
525
+ * RFC 2231's `key*`, `key*0`, `key*0*`, `key*1`…: the sections in order, their bytes joined before
526
+ * the charset decodes them, since a character may straddle two. Null when absent or odd.
527
+ */
528
+ const extendedParam = (params, key) => {
529
+ const ordered = [...params].flatMap(([name, value]) => {
530
+ if (name === `${key}*`) return [{
531
+ index: 0,
532
+ isEncoded: true,
533
+ value
534
+ }];
535
+ if (!name.startsWith(`${key}*`)) return [];
536
+ const suffix = /^(\d{1,3})(\*?)$/u.exec(name.slice(key.length + 1));
537
+ return suffix === null ? [] : [{
538
+ index: Number(suffix[1]),
539
+ isEncoded: suffix[2] === "*",
540
+ value
541
+ }];
542
+ }).toSorted((a, b) => a.index - b.index);
543
+ const [first] = ordered;
544
+ if (first === void 0 || ordered.some(({ index }, at) => index !== at)) return null;
545
+ const opening = first.isEncoded ? /^([^']*)'[^']*'(.*)$/su.exec(first.value) : null;
546
+ if (first.isEncoded && opening === null) return null;
547
+ const bytes = ordered.map(({ isEncoded, value }, at) => {
548
+ const text = at === 0 && opening !== null ? opening[2] ?? "" : value;
549
+ return isEncoded ? percentDecoded(text) : [...new TextEncoder().encode(text)];
550
+ });
551
+ if (bytes.some((section) => section === null)) return null;
552
+ const decoded = decodedIn(bytes.flatMap((section) => section ?? []), opening?.[1] ?? "");
553
+ return decoded === null || first.isEncoded ? decoded : decodeRfc2047(decoded);
554
+ };
555
+ const paramNamed = (params, key) => {
556
+ const plain = params.get(key);
557
+ return extendedParam(params, key) ?? (plain === void 0 ? null : decodeRfc2047(plain));
558
+ };
559
+ /** The disposition's file name wins over the type's older `name`. Anything odd is no name. */
560
+ const partName = (disposition, params) => {
561
+ const name = (paramNamed(disposition, "filename") ?? paramNamed(params, "name") ?? "").trim();
562
+ return name === "" || name.length > MAX_NAME_LENGTH ? null : name;
563
+ };
564
+ /** RFC 9051 body-type-1part: where the disposition sits differs by the part's type. */
565
+ const dispositionAt = (type, subtype) => {
566
+ if (type === "TEXT") return 9;
567
+ return type === "MESSAGE" && ["RFC822", "GLOBAL"].includes(subtype) ? 11 : 8;
568
+ };
569
+ const namesIn = (node, depth) => {
570
+ if (node?.kind !== "list" || depth > MAX_DEPTH) return [];
571
+ const [first, second] = node.items;
572
+ if (first?.kind === "list") return node.items.flatMap((child) => namesIn(child, depth + 1));
573
+ const type = textOf(first)?.toUpperCase() ?? "";
574
+ const subtype = textOf(second)?.toUpperCase() ?? "";
575
+ const disposition = node.items[dispositionAt(type, subtype)];
576
+ const own = partName(paramsIn(disposition?.kind === "list" ? disposition.items[1] : void 0), paramsIn(node.items[2]));
577
+ const nested = dispositionAt(type, subtype) === 11 ? namesIn(node.items[8], depth + 1) : [];
578
+ return [...own === null ? [] : [own], ...nested];
579
+ };
580
+ /**
581
+ * The names a message's parts carry, from a `BODYSTRUCTURE` or `BODY` response: `filename` or `name`
582
+ * in the plain, RFC 2231 and RFC 2047 forms, once each, in part order. Total: what cannot be read
583
+ * is no name, never a failure.
584
+ */
585
+ const parseBodyStructure = (tokens, start) => {
586
+ const { list, nextIndex } = readList(tokens, start);
587
+ return {
588
+ attachmentNames: [...new Set(namesIn(list ?? void 0, 0))].slice(0, MAX_NAMES),
589
+ nextIndex
590
+ };
591
+ };
592
+ //#endregion
408
593
  //#region src/envelope.ts
409
594
  const tokenToString$1 = (token) => {
410
595
  if (token === void 0 || token.kind === "nil") return null;
@@ -531,47 +716,6 @@ const parseEnvelope = (tokens, start) => {
531
716
  nextIndex: idx
532
717
  };
533
718
  };
534
- /** Only the MIME types, e.g. `['TEXT/PLAIN', 'TEXT/HTML']`. */
535
- const parseBodyStructureParts = (tokens, start) => {
536
- if (tokens[start]?.kind !== "lparen") return {
537
- parts: [],
538
- nextIndex: start + 1
539
- };
540
- let idx = start + 1;
541
- const parts = [];
542
- if (tokens[idx]?.kind === "lparen") {
543
- while (idx < tokens.length && tokens[idx]?.kind === "lparen") {
544
- const child = parseBodyStructureParts(tokens, idx);
545
- parts.push(...child.parts);
546
- idx = child.nextIndex;
547
- }
548
- let depth = 1;
549
- while (idx < tokens.length && depth > 0) {
550
- if (tokens[idx]?.kind === "lparen") depth++;
551
- else if (tokens[idx]?.kind === "rparen") depth--;
552
- idx++;
553
- }
554
- return {
555
- parts,
556
- nextIndex: idx
557
- };
558
- }
559
- const mediaType = tokenToString$1(tokens[idx]) ?? "APPLICATION";
560
- idx++;
561
- const subType = tokenToString$1(tokens[idx]) ?? "OCTET-STREAM";
562
- idx++;
563
- parts.push(`${mediaType.toUpperCase()}/${subType.toUpperCase()}`);
564
- let depth = 1;
565
- while (idx < tokens.length && depth > 0) {
566
- if (tokens[idx]?.kind === "lparen") depth++;
567
- else if (tokens[idx]?.kind === "rparen") depth--;
568
- idx++;
569
- }
570
- return {
571
- parts,
572
- nextIndex: idx
573
- };
574
- };
575
719
  const parseFetchItems = (tokens, start) => {
576
720
  if (tokens[start]?.kind !== "lparen") return {
577
721
  items: [],
@@ -637,12 +781,12 @@ const parseFetchItems = (tokens, start) => {
637
781
  } else idx++;
638
782
  } else if (itemName === "BODYSTRUCTURE" || itemName === "BODY" || itemName === "BODY.PEEK") {
639
783
  if (tokens[idx]?.kind === "lparen") {
640
- const bsResult = parseBodyStructureParts(tokens, idx);
784
+ const { attachmentNames, nextIndex } = parseBodyStructure(tokens, idx);
641
785
  items.push({
642
786
  kind: "bodyStructure",
643
- parts: bsResult.parts
787
+ attachmentNames
644
788
  });
645
- idx = bsResult.nextIndex;
789
+ idx = nextIndex;
646
790
  } else if (tokens[idx]?.kind === "lbracket") {
647
791
  idx++;
648
792
  const sectionParts = [];
@@ -1465,12 +1609,14 @@ const summariesFrom = (untagged) => untagged.flatMap((item) => {
1465
1609
  let envelope = null;
1466
1610
  let references = [];
1467
1611
  let gmailThreadId = null;
1612
+ let attachmentNames = [];
1468
1613
  for (const fItem of item.items) if (fItem.kind === "uid") uid = fItem.uid;
1469
1614
  else if (fItem.kind === "flags") flags = fItem.flags;
1470
1615
  else if (fItem.kind === "internalDate") internalDate = fItem.date;
1471
1616
  else if (fItem.kind === "size") size = fItem.size;
1472
1617
  else if (fItem.kind === "envelope") envelope = fItem.envelope;
1473
1618
  else if (fItem.kind === "gmailThreadId") gmailThreadId = fItem.id;
1619
+ else if (fItem.kind === "bodyStructure") attachmentNames = fItem.attachmentNames;
1474
1620
  else if (fItem.kind === "body" && fItem.section.toUpperCase().startsWith("HEADER.FIELDS")) references = parseReferencesHeader(fItem.bytes);
1475
1621
  return [{
1476
1622
  seq: item.seq,
@@ -1480,7 +1626,8 @@ const summariesFrom = (untagged) => untagged.flatMap((item) => {
1480
1626
  size,
1481
1627
  envelope,
1482
1628
  references,
1483
- gmailThreadId
1629
+ gmailThreadId,
1630
+ attachmentNames
1484
1631
  }];
1485
1632
  });
1486
1633
  const createImapClient = (transport, options) => {
@@ -1956,6 +2103,22 @@ const createImapClient = (transport, options) => {
1956
2103
  value: summariesFrom(res.value.untagged)
1957
2104
  };
1958
2105
  }),
2106
+ fetchAttachmentNames: (uidSet) => enqueueCommand(async () => {
2107
+ const res = await executeCommand((tag) => buildFetchBodyStructureCommand(tag, uidSet));
2108
+ if (!res.ok) return res;
2109
+ return {
2110
+ ok: true,
2111
+ value: res.value.untagged.flatMap((item) => {
2112
+ if (item.kind !== "fetch") return [];
2113
+ const uid = item.items.find(({ kind }) => kind === "uid");
2114
+ const structure = item.items.find(({ kind }) => kind === "bodyStructure");
2115
+ return uid?.kind === "uid" && structure?.kind === "bodyStructure" ? [{
2116
+ uid: uid.uid,
2117
+ attachmentNames: structure.attachmentNames
2118
+ }] : [];
2119
+ })
2120
+ };
2121
+ }),
1959
2122
  fetchFlags: (uidSet) => enqueueCommand(async () => {
1960
2123
  const res = await executeCommand((tag) => buildFetchFlagsCommand(tag, uidSet));
1961
2124
  if (!res.ok) return res;
@@ -1977,7 +2140,7 @@ const createImapClient = (transport, options) => {
1977
2140
  };
1978
2141
  }),
1979
2142
  fetchRaw: (uid) => enqueueCommand(async () => {
1980
- const res = await executeCommand((tag) => buildFetchRawCommand(tag, uid));
2143
+ const res = await executeCommand((tag) => buildFetchRawCommand(tag, String(uid)));
1981
2144
  if (!res.ok) return res;
1982
2145
  for (const item of res.value.untagged) if (item.kind === "fetch") {
1983
2146
  for (const fItem of item.items) if (fItem.kind === "body" && fItem.bytes !== null) return {
@@ -1993,6 +2156,22 @@ const createImapClient = (transport, options) => {
1993
2156
  }
1994
2157
  };
1995
2158
  }),
2159
+ fetchRawMessages: (uidSet) => enqueueCommand(async () => {
2160
+ const res = await executeCommand((tag) => buildFetchRawCommand(tag, uidSet));
2161
+ if (!res.ok) return res;
2162
+ return {
2163
+ ok: true,
2164
+ value: res.value.untagged.flatMap((item) => {
2165
+ if (item.kind !== "fetch") return [];
2166
+ const uid = item.items.find(({ kind }) => kind === "uid");
2167
+ const body = item.items.find(({ kind }) => kind === "body");
2168
+ return uid?.kind === "uid" && body?.kind === "body" && body.bytes !== null ? [{
2169
+ uid: uid.uid,
2170
+ bytes: body.bytes
2171
+ }] : [];
2172
+ })
2173
+ };
2174
+ }),
1996
2175
  storeFlags: (uidSet, mode, flags) => enqueueCommand(async () => {
1997
2176
  const res = await executeCommand((tag) => buildStoreFlagsCommand(tag, uidSet, mode, flags));
1998
2177
  if (!res.ok) return res;
@@ -2034,29 +2213,12 @@ const createImapClient = (transport, options) => {
2034
2213
  value: void 0
2035
2214
  };
2036
2215
  }),
2037
- uidSearchHeader: (header, value) => enqueueCommand(async () => {
2038
- const res = await executeCommand((tag) => buildUidSearchHeaderCommand(tag, header, value));
2216
+ uidSearch: (criteria) => enqueueCommand(async () => {
2217
+ const res = await executeCommand((tag) => buildUidSearchCommand(tag, criteria));
2039
2218
  if (!res.ok) return res;
2040
2219
  return {
2041
2220
  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
- "TO",
2051
- "TEXT"
2052
- ]) {
2053
- const res = await executeCommand((tag) => buildUidSearchTextCommand(tag, key, text));
2054
- if (!res.ok) return res;
2055
- for (const item of res.value.untagged) if (item.kind === "search") for (const uid of item.uids) found.add(uid);
2056
- }
2057
- return {
2058
- ok: true,
2059
- value: [...found].toSorted((a, b) => a - b)
2221
+ value: res.value.untagged.flatMap((item) => item.kind === "search" ? [...item.uids] : []).toSorted((a, b) => a - b)
2060
2222
  };
2061
2223
  }),
2062
2224
  move: (uidSet, mailbox) => enqueueCommand(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yozz.app/imap",
3
- "version": "0.1.6",
3
+ "version": "0.3.0",
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",
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^24.13.4",
33
- "@yozz.app/smtp": "0.1.6",
34
- "@yozz.app/tls": "0.1.6",
35
- "@yozz.app/x509": "0.1.6",
33
+ "@yozz.app/smtp": "0.3.0",
34
+ "@yozz.app/tls": "0.3.0",
35
+ "@yozz.app/x509": "0.3.0",
36
36
  "tsdown": "^0.23.0"
37
37
  },
38
38
  "scripts": {