@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 +9 -0
- package/dist/index.d.mts +48 -10
- package/dist/index.mjs +243 -81
- package/package.json +4 -4
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
|
|
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?:
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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> <
|
|
265
|
-
*
|
|
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
|
|
268
|
-
|
|
269
|
-
literal
|
|
270
|
-
|
|
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
|
|
784
|
+
const { attachmentNames, nextIndex } = parseBodyStructure(tokens, idx);
|
|
641
785
|
items.push({
|
|
642
786
|
kind: "bodyStructure",
|
|
643
|
-
|
|
787
|
+
attachmentNames
|
|
644
788
|
});
|
|
645
|
-
idx =
|
|
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
|
-
|
|
2038
|
-
const res = await executeCommand((tag) =>
|
|
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.
|
|
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.
|
|
34
|
-
"@yozz.app/tls": "0.
|
|
35
|
-
"@yozz.app/x509": "0.
|
|
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": {
|