@yozz.app/imap 0.1.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/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/index.d.mts +285 -0
- package/dist/index.mjs +2221 -0
- package/package.json +44 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2221 @@
|
|
|
1
|
+
//#region src/bytes.ts
|
|
2
|
+
const concatByteArrays = (arrays) => {
|
|
3
|
+
let totalLength = 0;
|
|
4
|
+
for (const arr of arrays) totalLength += arr.length;
|
|
5
|
+
const result = new Uint8Array(totalLength);
|
|
6
|
+
let offset = 0;
|
|
7
|
+
for (const arr of arrays) {
|
|
8
|
+
result.set(arr, offset);
|
|
9
|
+
offset += arr.length;
|
|
10
|
+
}
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
const asciiDecoder = new TextDecoder("ascii");
|
|
14
|
+
const textEncoder = new TextEncoder();
|
|
15
|
+
const asciiToString = (bytes) => asciiDecoder.decode(bytes);
|
|
16
|
+
const stringToBytes = (str) => textEncoder.encode(str);
|
|
17
|
+
const isDigit = (byte) => byte >= 48 && byte <= 57;
|
|
18
|
+
const B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
19
|
+
const bytesToBase64 = (bytes) => {
|
|
20
|
+
let result = "";
|
|
21
|
+
const len = bytes.length;
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < len) {
|
|
24
|
+
const b0 = bytes[i] ?? 0;
|
|
25
|
+
const b1 = i + 1 < len ? bytes[i + 1] ?? 0 : null;
|
|
26
|
+
const b2 = i + 2 < len ? bytes[i + 2] ?? 0 : null;
|
|
27
|
+
const idx0 = b0 >> 2;
|
|
28
|
+
result += B64_CHARS[idx0] ?? "";
|
|
29
|
+
if (b1 !== null) {
|
|
30
|
+
const idx1 = (b0 & 3) << 4 | b1 >> 4;
|
|
31
|
+
result += B64_CHARS[idx1] ?? "";
|
|
32
|
+
if (b2 !== null) {
|
|
33
|
+
const idx2 = (b1 & 15) << 2 | b2 >> 6;
|
|
34
|
+
const idx3 = b2 & 63;
|
|
35
|
+
result += (B64_CHARS[idx2] ?? "") + (B64_CHARS[idx3] ?? "");
|
|
36
|
+
i += 3;
|
|
37
|
+
} else {
|
|
38
|
+
const idx2 = (b1 & 15) << 2;
|
|
39
|
+
result += (B64_CHARS[idx2] ?? "") + "=";
|
|
40
|
+
i += 2;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
const idx1 = (b0 & 3) << 4;
|
|
44
|
+
result += (B64_CHARS[idx1] ?? "") + "==";
|
|
45
|
+
i += 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
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
|
+
*/
|
|
60
|
+
const b64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,";
|
|
61
|
+
const encodeModifiedBase64 = (codeUnits) => {
|
|
62
|
+
const bytes = [];
|
|
63
|
+
for (const cu of codeUnits) {
|
|
64
|
+
bytes.push(cu >> 8 & 255);
|
|
65
|
+
bytes.push(cu & 255);
|
|
66
|
+
}
|
|
67
|
+
let result = "";
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < bytes.length) {
|
|
70
|
+
const b0 = bytes[i] ?? 0;
|
|
71
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] ?? 0 : null;
|
|
72
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] ?? 0 : null;
|
|
73
|
+
const idx0 = b0 >> 2;
|
|
74
|
+
result += b64Chars[idx0] ?? "";
|
|
75
|
+
if (b1 !== null) {
|
|
76
|
+
const idx1 = (b0 & 3) << 4 | b1 >> 4;
|
|
77
|
+
result += b64Chars[idx1] ?? "";
|
|
78
|
+
if (b2 !== null) {
|
|
79
|
+
const idx2 = (b1 & 15) << 2 | b2 >> 6;
|
|
80
|
+
const idx3 = b2 & 63;
|
|
81
|
+
result += (b64Chars[idx2] ?? "") + (b64Chars[idx3] ?? "");
|
|
82
|
+
i += 3;
|
|
83
|
+
} else {
|
|
84
|
+
const idx2 = (b1 & 15) << 2;
|
|
85
|
+
result += b64Chars[idx2] ?? "";
|
|
86
|
+
i += 2;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const idx1 = (b0 & 3) << 4;
|
|
90
|
+
result += b64Chars[idx1] ?? "";
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
};
|
|
96
|
+
const decodeModifiedBase64 = (str) => {
|
|
97
|
+
const standardB64 = str.replace(/,/g, "/");
|
|
98
|
+
const remainder = standardB64.length % 4;
|
|
99
|
+
const padded = remainder === 0 ? standardB64 : standardB64 + "=".repeat(4 - remainder);
|
|
100
|
+
try {
|
|
101
|
+
const binary = atob(padded);
|
|
102
|
+
let decoded = "";
|
|
103
|
+
for (let i = 0; i + 1 < binary.length; i += 2) {
|
|
104
|
+
const high = binary.charCodeAt(i);
|
|
105
|
+
const low = binary.charCodeAt(i + 1);
|
|
106
|
+
decoded += String.fromCharCode(high << 8 | low);
|
|
107
|
+
}
|
|
108
|
+
return decoded;
|
|
109
|
+
} catch {
|
|
110
|
+
return `&${str}-`;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const encodeModifiedUtf7 = (str) => {
|
|
114
|
+
let result = "";
|
|
115
|
+
let nonAsciiBuffer = [];
|
|
116
|
+
const flushNonAscii = () => {
|
|
117
|
+
if (nonAsciiBuffer.length === 0) return;
|
|
118
|
+
result += `&${encodeModifiedBase64(nonAsciiBuffer)}-`;
|
|
119
|
+
nonAsciiBuffer = [];
|
|
120
|
+
};
|
|
121
|
+
for (let i = 0; i < str.length; i++) {
|
|
122
|
+
const code = str.charCodeAt(i);
|
|
123
|
+
if (code === 38) {
|
|
124
|
+
flushNonAscii();
|
|
125
|
+
result += "&-";
|
|
126
|
+
} else if (code >= 32 && code <= 126) {
|
|
127
|
+
flushNonAscii();
|
|
128
|
+
result += str[i] ?? "";
|
|
129
|
+
} else nonAsciiBuffer.push(code);
|
|
130
|
+
}
|
|
131
|
+
flushNonAscii();
|
|
132
|
+
return result;
|
|
133
|
+
};
|
|
134
|
+
const decodeModifiedUtf7 = (str) => {
|
|
135
|
+
let result = "";
|
|
136
|
+
let i = 0;
|
|
137
|
+
while (i < str.length) if (str[i] === "&") {
|
|
138
|
+
if (i + 1 < str.length && str[i + 1] === "-") {
|
|
139
|
+
result += "&";
|
|
140
|
+
i += 2;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const dashIndex = str.indexOf("-", i + 1);
|
|
144
|
+
if (dashIndex === -1) {
|
|
145
|
+
result += str.slice(i);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
const b64Part = str.slice(i + 1, dashIndex);
|
|
149
|
+
result += decodeModifiedBase64(b64Part);
|
|
150
|
+
i = dashIndex + 1;
|
|
151
|
+
} else {
|
|
152
|
+
result += str[i] ?? "";
|
|
153
|
+
i++;
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
};
|
|
157
|
+
//#endregion
|
|
158
|
+
//#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
|
+
const quoteString = (str) => {
|
|
164
|
+
return `"${str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
165
|
+
};
|
|
166
|
+
const isSafeQuotedString = (str) => {
|
|
167
|
+
for (let i = 0; i < str.length; i++) {
|
|
168
|
+
const code = str.charCodeAt(i);
|
|
169
|
+
if (code < 32 || code > 126 || code === 0) return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
};
|
|
173
|
+
const buildSaslPlainBytes = (user, pass) => {
|
|
174
|
+
const userBytes = stringToBytes(user);
|
|
175
|
+
const passBytes = stringToBytes(pass);
|
|
176
|
+
const result = new Uint8Array(1 + userBytes.length + 1 + passBytes.length);
|
|
177
|
+
result[0] = 0;
|
|
178
|
+
result.set(userBytes, 1);
|
|
179
|
+
result[1 + userBytes.length] = 0;
|
|
180
|
+
result.set(passBytes, 2 + userBytes.length);
|
|
181
|
+
return result;
|
|
182
|
+
};
|
|
183
|
+
const buildCapabilityCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} CAPABILITY\r\n`) }] });
|
|
184
|
+
const buildAuthenticatePlainSaslIrCommand = (tag, user, pass) => {
|
|
185
|
+
return { lines: [{ text: stringToBytes(`${tag} AUTHENTICATE PLAIN ${bytesToBase64(buildSaslPlainBytes(user, pass))}\r\n`) }] };
|
|
186
|
+
};
|
|
187
|
+
const buildAuthenticatePlainInitialCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} AUTHENTICATE PLAIN\r\n`) }] });
|
|
188
|
+
const buildAuthenticatePlainResponse = (user, pass) => {
|
|
189
|
+
return { lines: [{ text: stringToBytes(`${bytesToBase64(buildSaslPlainBytes(user, pass))}\r\n`) }] };
|
|
190
|
+
};
|
|
191
|
+
const buildLoginCommand = (tag, user, pass) => {
|
|
192
|
+
const userSafe = isSafeQuotedString(user);
|
|
193
|
+
const passSafe = isSafeQuotedString(pass);
|
|
194
|
+
if (userSafe && passSafe) return { lines: [{ text: stringToBytes(`${tag} LOGIN ${quoteString(user)} ${quoteString(pass)}\r\n`) }] };
|
|
195
|
+
if (!userSafe && passSafe) return { lines: [{
|
|
196
|
+
text: stringToBytes(`${tag} LOGIN `),
|
|
197
|
+
literal: stringToBytes(user)
|
|
198
|
+
}, { text: stringToBytes(` ${quoteString(pass)}\r\n`) }] };
|
|
199
|
+
if (userSafe && !passSafe) return { lines: [{
|
|
200
|
+
text: stringToBytes(`${tag} LOGIN ${quoteString(user)} `),
|
|
201
|
+
literal: stringToBytes(pass)
|
|
202
|
+
}, { text: stringToBytes("\r\n") }] };
|
|
203
|
+
return { lines: [
|
|
204
|
+
{
|
|
205
|
+
text: stringToBytes(`${tag} LOGIN `),
|
|
206
|
+
literal: stringToBytes(user)
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
text: stringToBytes(" "),
|
|
210
|
+
literal: stringToBytes(pass)
|
|
211
|
+
},
|
|
212
|
+
{ text: stringToBytes("\r\n") }
|
|
213
|
+
] };
|
|
214
|
+
};
|
|
215
|
+
const buildListCommand = (tag, reference, pattern) => {
|
|
216
|
+
const refUtf7 = encodeModifiedUtf7(reference);
|
|
217
|
+
const patUtf7 = encodeModifiedUtf7(pattern);
|
|
218
|
+
return { lines: [{ text: stringToBytes(`${tag} LIST ${quoteString(refUtf7)} ${quoteString(patUtf7)}\r\n`) }] };
|
|
219
|
+
};
|
|
220
|
+
const buildSelectCommand = (tag, mailbox, readOnly = false) => {
|
|
221
|
+
return { lines: [{ text: stringToBytes(`${tag} ${readOnly ? "EXAMINE" : "SELECT"} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] };
|
|
222
|
+
};
|
|
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
|
+
*/
|
|
231
|
+
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
|
+
const buildFetchFlagsCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (FLAGS UID)\r\n`) }] });
|
|
233
|
+
const buildFetchRawCommand = (tag, uid) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uid} (BODY.PEEK[])\r\n`) }] });
|
|
234
|
+
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`) }] };
|
|
236
|
+
};
|
|
237
|
+
/** APPEND with the message as a literal; flags are IMAP atoms such as `\\Seen`, sent unquoted. */
|
|
238
|
+
const buildAppendCommand = (tag, mailbox, flags, message) => {
|
|
239
|
+
return { lines: [{
|
|
240
|
+
text: stringToBytes(`${tag} APPEND ${quoteString(encodeModifiedUtf7(mailbox))} (${flags.join(" ")}) `),
|
|
241
|
+
literal: message
|
|
242
|
+
}, { text: stringToBytes("\r\n") }] };
|
|
243
|
+
};
|
|
244
|
+
/** RFC 6851 UID MOVE — relocates messages into another mailbox in one round trip. */
|
|
245
|
+
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
|
+
const buildCreateCommand = (tag, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} CREATE ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
|
|
248
|
+
const buildNoopCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} NOOP\r\n`) }] });
|
|
249
|
+
const buildIdleCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} IDLE\r\n`) }] });
|
|
250
|
+
/** RFC 2177: the client ends IDLE with a bare DONE line (no tag). */
|
|
251
|
+
const buildIdleDoneLine = () => stringToBytes("DONE\r\n");
|
|
252
|
+
const buildLogoutCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} LOGOUT\r\n`) }] });
|
|
253
|
+
//#endregion
|
|
254
|
+
//#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
|
+
const ENCODED_WORD_REGEX = /=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g;
|
|
266
|
+
const decodeQToBytes = (text) => {
|
|
267
|
+
const bytes = [];
|
|
268
|
+
let i = 0;
|
|
269
|
+
while (i < text.length) {
|
|
270
|
+
const ch = text[i];
|
|
271
|
+
if (ch === "_") {
|
|
272
|
+
bytes.push(32);
|
|
273
|
+
i++;
|
|
274
|
+
} else if (ch === "=" && i + 2 < text.length && /^[0-9a-fA-F]{2}$/.test(text.slice(i + 1, i + 3))) {
|
|
275
|
+
bytes.push(Number.parseInt(text.slice(i + 1, i + 3), 16));
|
|
276
|
+
i += 3;
|
|
277
|
+
} else {
|
|
278
|
+
bytes.push((text.charCodeAt(i) ?? 0) & 255);
|
|
279
|
+
i++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return new Uint8Array(bytes);
|
|
283
|
+
};
|
|
284
|
+
const decodeBToBytes = (text) => {
|
|
285
|
+
try {
|
|
286
|
+
const binary = atob(text.trim());
|
|
287
|
+
const bytes = new Uint8Array(binary.length);
|
|
288
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
289
|
+
return bytes;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const decodeBytesWithCharset = (bytes, charset) => {
|
|
295
|
+
try {
|
|
296
|
+
return new TextDecoder(charset).decode(bytes);
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const decodeRfc2047 = (header) => {
|
|
302
|
+
const matches = [];
|
|
303
|
+
let match = null;
|
|
304
|
+
ENCODED_WORD_REGEX.lastIndex = 0;
|
|
305
|
+
while (true) {
|
|
306
|
+
match = ENCODED_WORD_REGEX.exec(header);
|
|
307
|
+
if (match === null) break;
|
|
308
|
+
const raw = match[0];
|
|
309
|
+
const charset = match[1] ?? "";
|
|
310
|
+
const encodingLetter = (match[2] ?? "").toUpperCase();
|
|
311
|
+
const encodedText = match[3] ?? "";
|
|
312
|
+
const start = match.index;
|
|
313
|
+
const end = start + raw.length;
|
|
314
|
+
if (encodingLetter === "B" || encodingLetter === "Q") matches.push({
|
|
315
|
+
start,
|
|
316
|
+
end,
|
|
317
|
+
raw,
|
|
318
|
+
charset,
|
|
319
|
+
encoding: encodingLetter,
|
|
320
|
+
encodedText
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (matches.length === 0) return header;
|
|
324
|
+
let result = "";
|
|
325
|
+
let cursor = 0;
|
|
326
|
+
let i = 0;
|
|
327
|
+
while (i < matches.length) {
|
|
328
|
+
const current = matches[i];
|
|
329
|
+
if (current === void 0) break;
|
|
330
|
+
if (current.start > cursor) result += header.slice(cursor, current.start);
|
|
331
|
+
const adjacentWords = [current];
|
|
332
|
+
let nextIndex = i + 1;
|
|
333
|
+
while (nextIndex < matches.length) {
|
|
334
|
+
const prev = adjacentWords[adjacentWords.length - 1];
|
|
335
|
+
const next = matches[nextIndex];
|
|
336
|
+
if (prev === void 0 || next === void 0) break;
|
|
337
|
+
const between = header.slice(prev.end, next.start);
|
|
338
|
+
if (/^[ \t\r\n]+$/.test(between)) {
|
|
339
|
+
adjacentWords.push(next);
|
|
340
|
+
nextIndex++;
|
|
341
|
+
} else break;
|
|
342
|
+
}
|
|
343
|
+
let j = 0;
|
|
344
|
+
while (j < adjacentWords.length) {
|
|
345
|
+
const firstInCharset = adjacentWords[j];
|
|
346
|
+
if (firstInCharset === void 0) break;
|
|
347
|
+
const sameCharsetWords = [firstInCharset];
|
|
348
|
+
let k = j + 1;
|
|
349
|
+
while (k < adjacentWords.length) {
|
|
350
|
+
const candidate = adjacentWords[k];
|
|
351
|
+
if (candidate !== void 0 && candidate.charset.toLowerCase() === firstInCharset.charset.toLowerCase()) {
|
|
352
|
+
sameCharsetWords.push(candidate);
|
|
353
|
+
k++;
|
|
354
|
+
} else break;
|
|
355
|
+
}
|
|
356
|
+
let isCharsetSupported = true;
|
|
357
|
+
try {
|
|
358
|
+
new TextDecoder(firstInCharset.charset);
|
|
359
|
+
} catch {
|
|
360
|
+
isCharsetSupported = false;
|
|
361
|
+
}
|
|
362
|
+
if (!isCharsetSupported) for (const w of sameCharsetWords) result += w.raw;
|
|
363
|
+
else {
|
|
364
|
+
const byteChunks = [];
|
|
365
|
+
let hasDecodeError = false;
|
|
366
|
+
for (const w of sameCharsetWords) {
|
|
367
|
+
const chunk = w.encoding === "B" ? decodeBToBytes(w.encodedText) : decodeQToBytes(w.encodedText);
|
|
368
|
+
if (chunk === null) {
|
|
369
|
+
hasDecodeError = true;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
byteChunks.push(chunk);
|
|
373
|
+
}
|
|
374
|
+
if (hasDecodeError) for (const w of sameCharsetWords) result += w.raw;
|
|
375
|
+
else {
|
|
376
|
+
const decodedText = decodeBytesWithCharset(concatByteArrays(byteChunks), firstInCharset.charset);
|
|
377
|
+
if (decodedText !== null) result += decodedText;
|
|
378
|
+
else for (const w of sameCharsetWords) result += w.raw;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
j = k;
|
|
382
|
+
}
|
|
383
|
+
const lastWord = adjacentWords[adjacentWords.length - 1];
|
|
384
|
+
cursor = lastWord !== void 0 ? lastWord.end : current.end;
|
|
385
|
+
i = nextIndex;
|
|
386
|
+
}
|
|
387
|
+
if (cursor < header.length) result += header.slice(cursor);
|
|
388
|
+
return result;
|
|
389
|
+
};
|
|
390
|
+
//#endregion
|
|
391
|
+
//#region src/envelope.ts
|
|
392
|
+
/**
|
|
393
|
+
* Parsers for IMAP FETCH items: ENVELOPE, FLAGS, INTERNALDATE, RFC822.SIZE, BODYSTRUCTURE.
|
|
394
|
+
*/
|
|
395
|
+
const tokenToString$1 = (token) => {
|
|
396
|
+
if (token === void 0 || token.kind === "nil") return null;
|
|
397
|
+
if (token.kind === "quoted" || token.kind === "atom") return token.value;
|
|
398
|
+
if (token.kind === "number") return String(token.value);
|
|
399
|
+
if (token.kind === "literal") return asciiToString(token.value);
|
|
400
|
+
return null;
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* Parses a single address tuple `(name adl mailbox host)` and advances index past `)`.
|
|
404
|
+
*/
|
|
405
|
+
const parseAddressTuple = (tokens, start) => {
|
|
406
|
+
let idx = start;
|
|
407
|
+
if (tokens[idx]?.kind !== "lparen") return null;
|
|
408
|
+
idx++;
|
|
409
|
+
const rawName = tokenToString$1(tokens[idx]);
|
|
410
|
+
idx++;
|
|
411
|
+
idx++;
|
|
412
|
+
const rawMailbox = tokenToString$1(tokens[idx]);
|
|
413
|
+
idx++;
|
|
414
|
+
const rawHost = tokenToString$1(tokens[idx]);
|
|
415
|
+
idx++;
|
|
416
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") idx++;
|
|
417
|
+
if (tokens[idx]?.kind === "rparen") idx++;
|
|
418
|
+
const name = rawName !== null ? decodeRfc2047(rawName) : null;
|
|
419
|
+
const mailbox = rawMailbox;
|
|
420
|
+
const host = rawHost;
|
|
421
|
+
if (host === null) {
|
|
422
|
+
if (mailbox !== null) return {
|
|
423
|
+
address: "group-start",
|
|
424
|
+
nextIndex: idx
|
|
425
|
+
};
|
|
426
|
+
return {
|
|
427
|
+
address: "group-end",
|
|
428
|
+
nextIndex: idx
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
address: {
|
|
433
|
+
name,
|
|
434
|
+
mailbox,
|
|
435
|
+
host
|
|
436
|
+
},
|
|
437
|
+
nextIndex: idx
|
|
438
|
+
};
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
441
|
+
* Parses an address list `( (addr1) (addr2) ... )` or `NIL`.
|
|
442
|
+
* Flattening groups to their member addresses.
|
|
443
|
+
*/
|
|
444
|
+
const parseAddressList = (tokens, start) => {
|
|
445
|
+
const first = tokens[start];
|
|
446
|
+
if (first === void 0 || first.kind === "nil") return {
|
|
447
|
+
addresses: [],
|
|
448
|
+
nextIndex: start + 1
|
|
449
|
+
};
|
|
450
|
+
if (first.kind !== "lparen") return {
|
|
451
|
+
addresses: [],
|
|
452
|
+
nextIndex: start + 1
|
|
453
|
+
};
|
|
454
|
+
let idx = start + 1;
|
|
455
|
+
const addresses = [];
|
|
456
|
+
while (idx < tokens.length) {
|
|
457
|
+
const tok = tokens[idx];
|
|
458
|
+
if (tok === void 0 || tok.kind === "rparen") {
|
|
459
|
+
idx++;
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
if (tok.kind === "lparen") {
|
|
463
|
+
const parsed = parseAddressTuple(tokens, idx);
|
|
464
|
+
if (parsed === null) idx++;
|
|
465
|
+
else {
|
|
466
|
+
if (parsed.address !== "group-start" && parsed.address !== "group-end") addresses.push(parsed.address);
|
|
467
|
+
idx = parsed.nextIndex;
|
|
468
|
+
}
|
|
469
|
+
} else idx++;
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
addresses,
|
|
473
|
+
nextIndex: idx
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* Parses an ENVELOPE structure:
|
|
478
|
+
* `(date subject from sender reply-to to cc bcc in-reply-to message-id)`
|
|
479
|
+
*/
|
|
480
|
+
const parseEnvelope = (tokens, start) => {
|
|
481
|
+
if (tokens[start]?.kind !== "lparen") return null;
|
|
482
|
+
let idx = start + 1;
|
|
483
|
+
const date = tokenToString$1(tokens[idx]);
|
|
484
|
+
idx++;
|
|
485
|
+
const subjectRaw = tokenToString$1(tokens[idx]);
|
|
486
|
+
const subject = subjectRaw !== null ? decodeRfc2047(subjectRaw) : null;
|
|
487
|
+
idx++;
|
|
488
|
+
const fromResult = parseAddressList(tokens, idx);
|
|
489
|
+
const from = fromResult.addresses;
|
|
490
|
+
idx = fromResult.nextIndex;
|
|
491
|
+
const senderResult = parseAddressList(tokens, idx);
|
|
492
|
+
const sender = senderResult.addresses;
|
|
493
|
+
idx = senderResult.nextIndex;
|
|
494
|
+
const replyToResult = parseAddressList(tokens, idx);
|
|
495
|
+
const replyTo = replyToResult.addresses;
|
|
496
|
+
idx = replyToResult.nextIndex;
|
|
497
|
+
const toResult = parseAddressList(tokens, idx);
|
|
498
|
+
const to = toResult.addresses;
|
|
499
|
+
idx = toResult.nextIndex;
|
|
500
|
+
const ccResult = parseAddressList(tokens, idx);
|
|
501
|
+
const cc = ccResult.addresses;
|
|
502
|
+
idx = ccResult.nextIndex;
|
|
503
|
+
const bccResult = parseAddressList(tokens, idx);
|
|
504
|
+
const bcc = bccResult.addresses;
|
|
505
|
+
idx = bccResult.nextIndex;
|
|
506
|
+
const inReplyTo = tokenToString$1(tokens[idx]);
|
|
507
|
+
idx++;
|
|
508
|
+
const messageId = tokenToString$1(tokens[idx]);
|
|
509
|
+
idx++;
|
|
510
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") idx++;
|
|
511
|
+
if (tokens[idx]?.kind === "rparen") idx++;
|
|
512
|
+
return {
|
|
513
|
+
envelope: {
|
|
514
|
+
date,
|
|
515
|
+
subject,
|
|
516
|
+
subjectRaw,
|
|
517
|
+
from,
|
|
518
|
+
sender,
|
|
519
|
+
replyTo,
|
|
520
|
+
to,
|
|
521
|
+
cc,
|
|
522
|
+
bcc,
|
|
523
|
+
inReplyTo,
|
|
524
|
+
messageId
|
|
525
|
+
},
|
|
526
|
+
nextIndex: idx
|
|
527
|
+
};
|
|
528
|
+
};
|
|
529
|
+
/**
|
|
530
|
+
* Parses BODYSTRUCTURE, extracting only the list of MIME parts (e.g. ['TEXT/PLAIN', 'TEXT/HTML']).
|
|
531
|
+
*/
|
|
532
|
+
const parseBodyStructureParts = (tokens, start) => {
|
|
533
|
+
if (tokens[start]?.kind !== "lparen") return {
|
|
534
|
+
parts: [],
|
|
535
|
+
nextIndex: start + 1
|
|
536
|
+
};
|
|
537
|
+
let idx = start + 1;
|
|
538
|
+
const parts = [];
|
|
539
|
+
if (tokens[idx]?.kind === "lparen") {
|
|
540
|
+
while (idx < tokens.length && tokens[idx]?.kind === "lparen") {
|
|
541
|
+
const child = parseBodyStructureParts(tokens, idx);
|
|
542
|
+
parts.push(...child.parts);
|
|
543
|
+
idx = child.nextIndex;
|
|
544
|
+
}
|
|
545
|
+
let depth = 1;
|
|
546
|
+
while (idx < tokens.length && depth > 0) {
|
|
547
|
+
if (tokens[idx]?.kind === "lparen") depth++;
|
|
548
|
+
else if (tokens[idx]?.kind === "rparen") depth--;
|
|
549
|
+
idx++;
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
parts,
|
|
553
|
+
nextIndex: idx
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const mediaType = tokenToString$1(tokens[idx]) ?? "APPLICATION";
|
|
557
|
+
idx++;
|
|
558
|
+
const subType = tokenToString$1(tokens[idx]) ?? "OCTET-STREAM";
|
|
559
|
+
idx++;
|
|
560
|
+
parts.push(`${mediaType.toUpperCase()}/${subType.toUpperCase()}`);
|
|
561
|
+
let depth = 1;
|
|
562
|
+
while (idx < tokens.length && depth > 0) {
|
|
563
|
+
if (tokens[idx]?.kind === "lparen") depth++;
|
|
564
|
+
else if (tokens[idx]?.kind === "rparen") depth--;
|
|
565
|
+
idx++;
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
parts,
|
|
569
|
+
nextIndex: idx
|
|
570
|
+
};
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* Parses items inside a FETCH response `(...)`.
|
|
574
|
+
*/
|
|
575
|
+
const parseFetchItems = (tokens, start) => {
|
|
576
|
+
if (tokens[start]?.kind !== "lparen") return {
|
|
577
|
+
items: [],
|
|
578
|
+
nextIndex: start + 1
|
|
579
|
+
};
|
|
580
|
+
let idx = start + 1;
|
|
581
|
+
const items = [];
|
|
582
|
+
while (idx < tokens.length) {
|
|
583
|
+
const tok = tokens[idx];
|
|
584
|
+
if (tok === void 0 || tok.kind === "rparen") {
|
|
585
|
+
idx++;
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
if (tok.kind === "atom") {
|
|
589
|
+
const itemName = tok.value.toUpperCase();
|
|
590
|
+
idx++;
|
|
591
|
+
if (itemName === "UID") {
|
|
592
|
+
const uidTok = tokens[idx];
|
|
593
|
+
const uid = uidTok?.kind === "number" ? uidTok.value : 0;
|
|
594
|
+
idx++;
|
|
595
|
+
items.push({
|
|
596
|
+
kind: "uid",
|
|
597
|
+
uid
|
|
598
|
+
});
|
|
599
|
+
} else if (itemName === "RFC822.SIZE") {
|
|
600
|
+
const sizeTok = tokens[idx];
|
|
601
|
+
const size = sizeTok?.kind === "number" ? sizeTok.value : 0;
|
|
602
|
+
idx++;
|
|
603
|
+
items.push({
|
|
604
|
+
kind: "size",
|
|
605
|
+
size
|
|
606
|
+
});
|
|
607
|
+
} else if (itemName === "INTERNALDATE") {
|
|
608
|
+
const dateStr = tokenToString$1(tokens[idx]) ?? "";
|
|
609
|
+
idx++;
|
|
610
|
+
items.push({
|
|
611
|
+
kind: "internalDate",
|
|
612
|
+
date: dateStr
|
|
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);
|
|
620
|
+
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") {
|
|
629
|
+
const envResult = parseEnvelope(tokens, idx);
|
|
630
|
+
if (envResult !== null) {
|
|
631
|
+
items.push({
|
|
632
|
+
kind: "envelope",
|
|
633
|
+
envelope: envResult.envelope
|
|
634
|
+
});
|
|
635
|
+
idx = envResult.nextIndex;
|
|
636
|
+
} 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));
|
|
656
|
+
}
|
|
657
|
+
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") {
|
|
676
|
+
const id = tokenToString$1(tokens[idx]);
|
|
677
|
+
idx++;
|
|
678
|
+
if (id !== null && /^\d+$/.test(id)) items.push({
|
|
679
|
+
kind: "gmailThreadId",
|
|
680
|
+
id
|
|
681
|
+
});
|
|
682
|
+
} else {
|
|
683
|
+
items.push({
|
|
684
|
+
kind: "other",
|
|
685
|
+
name: itemName
|
|
686
|
+
});
|
|
687
|
+
idx++;
|
|
688
|
+
}
|
|
689
|
+
} else idx++;
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
items,
|
|
693
|
+
nextIndex: idx
|
|
694
|
+
};
|
|
695
|
+
};
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/response.ts
|
|
698
|
+
/**
|
|
699
|
+
* IMAP response parser: converts token stream into typed tagged, untagged, and continuation responses.
|
|
700
|
+
*/
|
|
701
|
+
const tokenToString = (token) => {
|
|
702
|
+
if (token === void 0 || token.kind === "nil") return null;
|
|
703
|
+
if (token.kind === "quoted" || token.kind === "atom") return token.value;
|
|
704
|
+
if (token.kind === "number") return String(token.value);
|
|
705
|
+
if (token.kind === "literal") return asciiToString(token.value);
|
|
706
|
+
return null;
|
|
707
|
+
};
|
|
708
|
+
const tokensToText = (tokens, start) => {
|
|
709
|
+
const parts = [];
|
|
710
|
+
for (let i = start; i < tokens.length; i++) {
|
|
711
|
+
const tok = tokens[i];
|
|
712
|
+
if (tok === void 0) continue;
|
|
713
|
+
if (tok.kind === "atom" || tok.kind === "quoted") parts.push(tok.value);
|
|
714
|
+
else if (tok.kind === "number") parts.push(String(tok.value));
|
|
715
|
+
else if (tok.kind === "literal") parts.push(asciiToString(tok.value));
|
|
716
|
+
else if (tok.kind === "plus") parts.push("+");
|
|
717
|
+
else if (tok.kind === "nil") parts.push("NIL");
|
|
718
|
+
else if (tok.kind === "lparen") parts.push("(");
|
|
719
|
+
else if (tok.kind === "rparen") parts.push(")");
|
|
720
|
+
else if (tok.kind === "lbracket") parts.push("[");
|
|
721
|
+
else if (tok.kind === "rbracket") parts.push("]");
|
|
722
|
+
}
|
|
723
|
+
return parts.join(" ");
|
|
724
|
+
};
|
|
725
|
+
const parseResponseCode = (tokens, start) => {
|
|
726
|
+
if (tokens[start]?.kind !== "lbracket") return null;
|
|
727
|
+
let idx = start + 1;
|
|
728
|
+
const codeTok = tokens[idx];
|
|
729
|
+
if (codeTok?.kind !== "atom") return null;
|
|
730
|
+
const codeName = codeTok.value.toUpperCase();
|
|
731
|
+
idx++;
|
|
732
|
+
if (codeName === "CAPABILITY") {
|
|
733
|
+
const capabilities = [];
|
|
734
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") {
|
|
735
|
+
const capTok = tokens[idx];
|
|
736
|
+
if (capTok?.kind === "atom") capabilities.push(capTok.value);
|
|
737
|
+
idx++;
|
|
738
|
+
}
|
|
739
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
740
|
+
return {
|
|
741
|
+
code: {
|
|
742
|
+
kind: "capability",
|
|
743
|
+
capabilities
|
|
744
|
+
},
|
|
745
|
+
nextIndex: idx
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (codeName === "PERMANENTFLAGS") {
|
|
749
|
+
const flags = [];
|
|
750
|
+
if (tokens[idx]?.kind === "lparen") {
|
|
751
|
+
idx++;
|
|
752
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
|
|
753
|
+
const flagTok = tokens[idx];
|
|
754
|
+
if (flagTok?.kind === "atom") flags.push(flagTok.value);
|
|
755
|
+
idx++;
|
|
756
|
+
}
|
|
757
|
+
if (tokens[idx]?.kind === "rparen") idx++;
|
|
758
|
+
}
|
|
759
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") idx++;
|
|
760
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
761
|
+
return {
|
|
762
|
+
code: {
|
|
763
|
+
kind: "permanentFlags",
|
|
764
|
+
flags
|
|
765
|
+
},
|
|
766
|
+
nextIndex: idx
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
if (codeName === "UIDVALIDITY") {
|
|
770
|
+
const valTok = tokens[idx];
|
|
771
|
+
const value = valTok?.kind === "number" ? valTok.value : 0;
|
|
772
|
+
idx++;
|
|
773
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") idx++;
|
|
774
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
775
|
+
return {
|
|
776
|
+
code: {
|
|
777
|
+
kind: "uidValidity",
|
|
778
|
+
value
|
|
779
|
+
},
|
|
780
|
+
nextIndex: idx
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
if (codeName === "UIDNEXT") {
|
|
784
|
+
const valTok = tokens[idx];
|
|
785
|
+
const value = valTok?.kind === "number" ? valTok.value : 0;
|
|
786
|
+
idx++;
|
|
787
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") idx++;
|
|
788
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
789
|
+
return {
|
|
790
|
+
code: {
|
|
791
|
+
kind: "uidNext",
|
|
792
|
+
value
|
|
793
|
+
},
|
|
794
|
+
nextIndex: idx
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (codeName === "READ-ONLY") {
|
|
798
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") idx++;
|
|
799
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
800
|
+
return {
|
|
801
|
+
code: { kind: "readOnly" },
|
|
802
|
+
nextIndex: idx
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
if (codeName === "READ-WRITE") {
|
|
806
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") idx++;
|
|
807
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
808
|
+
return {
|
|
809
|
+
code: { kind: "readWrite" },
|
|
810
|
+
nextIndex: idx
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
if (codeName === "ALERT") {
|
|
814
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") idx++;
|
|
815
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
816
|
+
return {
|
|
817
|
+
code: { kind: "alert" },
|
|
818
|
+
nextIndex: idx
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
const args = [];
|
|
822
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") {
|
|
823
|
+
const str = tokenToString(tokens[idx]);
|
|
824
|
+
if (str !== null) args.push(str);
|
|
825
|
+
idx++;
|
|
826
|
+
}
|
|
827
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
828
|
+
return {
|
|
829
|
+
code: {
|
|
830
|
+
kind: "other",
|
|
831
|
+
code: codeName,
|
|
832
|
+
args
|
|
833
|
+
},
|
|
834
|
+
nextIndex: idx
|
|
835
|
+
};
|
|
836
|
+
};
|
|
837
|
+
const parseResponse = (tokens) => {
|
|
838
|
+
if (tokens.length === 0) return {
|
|
839
|
+
ok: false,
|
|
840
|
+
reason: {
|
|
841
|
+
kind: "protocol",
|
|
842
|
+
detail: "Empty response line"
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
const first = tokens[0];
|
|
846
|
+
if (first === void 0) return {
|
|
847
|
+
ok: false,
|
|
848
|
+
reason: {
|
|
849
|
+
kind: "protocol",
|
|
850
|
+
detail: "Empty token stream"
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
if (first.kind === "plus") return {
|
|
854
|
+
ok: true,
|
|
855
|
+
value: {
|
|
856
|
+
kind: "continuation",
|
|
857
|
+
text: tokensToText(tokens, 1)
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
if (first.kind === "atom" && first.value === "*") {
|
|
861
|
+
const second = tokens[1];
|
|
862
|
+
if (second === void 0) return {
|
|
863
|
+
ok: false,
|
|
864
|
+
reason: {
|
|
865
|
+
kind: "protocol",
|
|
866
|
+
detail: "Bare * untagged marker"
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
if (second.kind === "atom") {
|
|
870
|
+
const upper = second.value.toUpperCase();
|
|
871
|
+
if (upper === "OK" || upper === "NO" || upper === "BAD" || upper === "BYE" || upper === "PREAUTH") {
|
|
872
|
+
let idx = 2;
|
|
873
|
+
let code = null;
|
|
874
|
+
if (tokens[idx]?.kind === "lbracket") {
|
|
875
|
+
const codeResult = parseResponseCode(tokens, idx);
|
|
876
|
+
if (codeResult !== null) {
|
|
877
|
+
code = codeResult.code;
|
|
878
|
+
idx = codeResult.nextIndex;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const text = tokensToText(tokens, idx);
|
|
882
|
+
return {
|
|
883
|
+
ok: true,
|
|
884
|
+
value: {
|
|
885
|
+
kind: "untagged",
|
|
886
|
+
untagged: {
|
|
887
|
+
kind: "status",
|
|
888
|
+
status: upper,
|
|
889
|
+
code,
|
|
890
|
+
text
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (upper === "CAPABILITY") {
|
|
896
|
+
const capabilities = [];
|
|
897
|
+
for (let i = 2; i < tokens.length; i++) {
|
|
898
|
+
const tok = tokens[i];
|
|
899
|
+
if (tok?.kind === "atom") capabilities.push(tok.value);
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
ok: true,
|
|
903
|
+
value: {
|
|
904
|
+
kind: "untagged",
|
|
905
|
+
untagged: {
|
|
906
|
+
kind: "capability",
|
|
907
|
+
capabilities
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
if (upper === "FLAGS") {
|
|
913
|
+
const flags = [];
|
|
914
|
+
let idx = 2;
|
|
915
|
+
if (tokens[idx]?.kind === "lparen") {
|
|
916
|
+
idx++;
|
|
917
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
|
|
918
|
+
const tok = tokens[idx];
|
|
919
|
+
if (tok?.kind === "atom") flags.push(tok.value);
|
|
920
|
+
idx++;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
ok: true,
|
|
925
|
+
value: {
|
|
926
|
+
kind: "untagged",
|
|
927
|
+
untagged: {
|
|
928
|
+
kind: "flags",
|
|
929
|
+
flags
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
if (upper === "LIST") {
|
|
935
|
+
let idx = 2;
|
|
936
|
+
const attributes = [];
|
|
937
|
+
if (tokens[idx]?.kind === "lparen") {
|
|
938
|
+
idx++;
|
|
939
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
|
|
940
|
+
const tok = tokens[idx];
|
|
941
|
+
if (tok?.kind === "atom") attributes.push(tok.value);
|
|
942
|
+
idx++;
|
|
943
|
+
}
|
|
944
|
+
if (tokens[idx]?.kind === "rparen") idx++;
|
|
945
|
+
}
|
|
946
|
+
const delimiter = tokenToString(tokens[idx]);
|
|
947
|
+
idx++;
|
|
948
|
+
return {
|
|
949
|
+
ok: true,
|
|
950
|
+
value: {
|
|
951
|
+
kind: "untagged",
|
|
952
|
+
untagged: {
|
|
953
|
+
kind: "list",
|
|
954
|
+
mailbox: {
|
|
955
|
+
name: decodeModifiedUtf7(tokenToString(tokens[idx]) ?? ""),
|
|
956
|
+
delimiter,
|
|
957
|
+
attributes
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
if (upper === "SEARCH") {
|
|
964
|
+
const uids = [];
|
|
965
|
+
for (let i = 2; i < tokens.length; i++) {
|
|
966
|
+
const tok = tokens[i];
|
|
967
|
+
if (tok?.kind === "number") uids.push(tok.value);
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
ok: true,
|
|
971
|
+
value: {
|
|
972
|
+
kind: "untagged",
|
|
973
|
+
untagged: {
|
|
974
|
+
kind: "search",
|
|
975
|
+
uids
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
if (second.kind === "number") {
|
|
982
|
+
const num = second.value;
|
|
983
|
+
const third = tokens[2];
|
|
984
|
+
if (third?.kind === "atom") {
|
|
985
|
+
const verb = third.value.toUpperCase();
|
|
986
|
+
if (verb === "EXISTS") return {
|
|
987
|
+
ok: true,
|
|
988
|
+
value: {
|
|
989
|
+
kind: "untagged",
|
|
990
|
+
untagged: {
|
|
991
|
+
kind: "exists",
|
|
992
|
+
count: num
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
if (verb === "RECENT") return {
|
|
997
|
+
ok: true,
|
|
998
|
+
value: {
|
|
999
|
+
kind: "untagged",
|
|
1000
|
+
untagged: {
|
|
1001
|
+
kind: "recent",
|
|
1002
|
+
count: num
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
if (verb === "EXPUNGE") return {
|
|
1007
|
+
ok: true,
|
|
1008
|
+
value: {
|
|
1009
|
+
kind: "untagged",
|
|
1010
|
+
untagged: {
|
|
1011
|
+
kind: "expunge",
|
|
1012
|
+
seq: num
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
if (verb === "FETCH") return {
|
|
1017
|
+
ok: true,
|
|
1018
|
+
value: {
|
|
1019
|
+
kind: "untagged",
|
|
1020
|
+
untagged: {
|
|
1021
|
+
kind: "fetch",
|
|
1022
|
+
seq: num,
|
|
1023
|
+
items: parseFetchItems(tokens, 3).items
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const name = second.kind === "atom" ? second.value : String(tokenToString(second));
|
|
1030
|
+
const rest = [];
|
|
1031
|
+
for (let i = 2; i < tokens.length; i++) {
|
|
1032
|
+
const str = tokenToString(tokens[i]);
|
|
1033
|
+
if (str !== null) rest.push(str);
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
ok: true,
|
|
1037
|
+
value: {
|
|
1038
|
+
kind: "untagged",
|
|
1039
|
+
untagged: {
|
|
1040
|
+
kind: "unknown",
|
|
1041
|
+
name,
|
|
1042
|
+
rest
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
if (first.kind === "atom") {
|
|
1048
|
+
const tag = first.value;
|
|
1049
|
+
const second = tokens[1];
|
|
1050
|
+
if (second?.kind === "atom") {
|
|
1051
|
+
const statusUpper = second.value.toUpperCase();
|
|
1052
|
+
if (statusUpper === "OK" || statusUpper === "NO" || statusUpper === "BAD") {
|
|
1053
|
+
let idx = 2;
|
|
1054
|
+
let code = null;
|
|
1055
|
+
if (tokens[idx]?.kind === "lbracket") {
|
|
1056
|
+
const codeResult = parseResponseCode(tokens, idx);
|
|
1057
|
+
if (codeResult !== null) {
|
|
1058
|
+
code = codeResult.code;
|
|
1059
|
+
idx = codeResult.nextIndex;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const text = tokensToText(tokens, idx);
|
|
1063
|
+
return {
|
|
1064
|
+
ok: true,
|
|
1065
|
+
value: {
|
|
1066
|
+
kind: "tagged",
|
|
1067
|
+
tag,
|
|
1068
|
+
status: statusUpper,
|
|
1069
|
+
code,
|
|
1070
|
+
text
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
ok: false,
|
|
1078
|
+
reason: {
|
|
1079
|
+
kind: "protocol",
|
|
1080
|
+
detail: `Unrecognised IMAP response line starting with ${first.kind}`
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
};
|
|
1084
|
+
//#endregion
|
|
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;
|
|
1098
|
+
const MAX_UINT32 = 4294967295;
|
|
1099
|
+
/**
|
|
1100
|
+
* Checks for bare LF in a slice of bytes.
|
|
1101
|
+
*/
|
|
1102
|
+
const checkBareLf = (bytes, start, end) => {
|
|
1103
|
+
for (let i = start; i < end; i++) if (bytes[i] === 10 && (i === 0 || bytes[i - 1] !== 13)) return {
|
|
1104
|
+
kind: "protocol",
|
|
1105
|
+
detail: `Bare LF at index ${i}`
|
|
1106
|
+
};
|
|
1107
|
+
return null;
|
|
1108
|
+
};
|
|
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
|
+
const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
|
|
1114
|
+
if (crlfIndex === 0) return { kind: "none" };
|
|
1115
|
+
let i = crlfIndex - 1;
|
|
1116
|
+
if (bytes[i] !== 125) return { kind: "none" };
|
|
1117
|
+
i--;
|
|
1118
|
+
if (i >= textStart && bytes[i] === 43) i--;
|
|
1119
|
+
const digitsEnd = i + 1;
|
|
1120
|
+
while (i >= textStart && isDigit(bytes[i] ?? 0)) i--;
|
|
1121
|
+
if (i < textStart || bytes[i] !== 123) {
|
|
1122
|
+
for (let j = crlfIndex - 2; j >= textStart; j--) if (bytes[j] === 123) return {
|
|
1123
|
+
kind: "invalid",
|
|
1124
|
+
detail: "Invalid literal header syntax"
|
|
1125
|
+
};
|
|
1126
|
+
return { kind: "none" };
|
|
1127
|
+
}
|
|
1128
|
+
const braceIndex = i;
|
|
1129
|
+
const digitsStart = i + 1;
|
|
1130
|
+
if (digitsStart >= digitsEnd) return {
|
|
1131
|
+
kind: "invalid",
|
|
1132
|
+
detail: "Empty literal length in literal header"
|
|
1133
|
+
};
|
|
1134
|
+
const digitsStr = asciiToString(bytes.slice(digitsStart, digitsEnd));
|
|
1135
|
+
const num = Number.parseInt(digitsStr, 10);
|
|
1136
|
+
if (!Number.isSafeInteger(num) || num > MAX_UINT32) return {
|
|
1137
|
+
kind: "invalid",
|
|
1138
|
+
detail: `Literal length ${digitsStr} is not a valid 32-bit unsigned integer`
|
|
1139
|
+
};
|
|
1140
|
+
if (num > maxLiteralBytes) return {
|
|
1141
|
+
kind: "invalid",
|
|
1142
|
+
detail: `Literal of ${num} bytes exceeds maxLiteralBytes (${maxLiteralBytes})`
|
|
1143
|
+
};
|
|
1144
|
+
return {
|
|
1145
|
+
kind: "valid",
|
|
1146
|
+
length: num,
|
|
1147
|
+
braceIndex
|
|
1148
|
+
};
|
|
1149
|
+
};
|
|
1150
|
+
/**
|
|
1151
|
+
* Scans `buffer` for a complete logical line (including any literals).
|
|
1152
|
+
*/
|
|
1153
|
+
const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, resume) => {
|
|
1154
|
+
const segments = resume?.segments ? [...resume.segments] : [];
|
|
1155
|
+
let scanOffset = resume?.resumeAt ?? 0;
|
|
1156
|
+
let textStart = resume?.textStart ?? resume?.resumeAt ?? 0;
|
|
1157
|
+
if (resume?.pendingLiteral !== void 0) {
|
|
1158
|
+
const { start, end } = resume.pendingLiteral;
|
|
1159
|
+
if (buffer.length < end) return {
|
|
1160
|
+
status: "incomplete",
|
|
1161
|
+
resumeAt: scanOffset,
|
|
1162
|
+
needBytes: end,
|
|
1163
|
+
segments,
|
|
1164
|
+
textStart,
|
|
1165
|
+
pendingLiteral: resume.pendingLiteral
|
|
1166
|
+
};
|
|
1167
|
+
segments.push({
|
|
1168
|
+
kind: "literal",
|
|
1169
|
+
bytes: buffer.slice(start, end)
|
|
1170
|
+
});
|
|
1171
|
+
scanOffset = end;
|
|
1172
|
+
textStart = end;
|
|
1173
|
+
}
|
|
1174
|
+
while (scanOffset < buffer.length) {
|
|
1175
|
+
let crlfIndex = -1;
|
|
1176
|
+
for (let i = scanOffset; i < buffer.length - 1; i++) if (buffer[i] === 13 && buffer[i + 1] === 10) {
|
|
1177
|
+
crlfIndex = i;
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
if (crlfIndex === -1) {
|
|
1181
|
+
const lfFailure = checkBareLf(buffer, scanOffset, buffer.length);
|
|
1182
|
+
if (lfFailure !== null) return {
|
|
1183
|
+
status: "failure",
|
|
1184
|
+
failure: lfFailure
|
|
1185
|
+
};
|
|
1186
|
+
return {
|
|
1187
|
+
status: "incomplete",
|
|
1188
|
+
resumeAt: Math.max(textStart, buffer.length - 1),
|
|
1189
|
+
needBytes: buffer.length + 1,
|
|
1190
|
+
segments,
|
|
1191
|
+
textStart
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
const lfFailure = checkBareLf(buffer, scanOffset, crlfIndex);
|
|
1195
|
+
if (lfFailure !== null) return {
|
|
1196
|
+
status: "failure",
|
|
1197
|
+
failure: lfFailure
|
|
1198
|
+
};
|
|
1199
|
+
const headerResult = parseLiteralHeader(buffer, textStart, crlfIndex, maxLiteralBytes);
|
|
1200
|
+
if (headerResult.kind === "invalid") return {
|
|
1201
|
+
status: "failure",
|
|
1202
|
+
failure: {
|
|
1203
|
+
kind: "protocol",
|
|
1204
|
+
detail: headerResult.detail
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
if (headerResult.kind === "valid") {
|
|
1208
|
+
const braceIndex = headerResult.braceIndex;
|
|
1209
|
+
const literalLength = headerResult.length;
|
|
1210
|
+
if (braceIndex > textStart) segments.push({
|
|
1211
|
+
kind: "text",
|
|
1212
|
+
bytes: buffer.slice(textStart, braceIndex)
|
|
1213
|
+
});
|
|
1214
|
+
const literalDataStart = crlfIndex + 2;
|
|
1215
|
+
const literalDataEnd = literalDataStart + literalLength;
|
|
1216
|
+
if (buffer.length < literalDataEnd) return {
|
|
1217
|
+
status: "incomplete",
|
|
1218
|
+
resumeAt: crlfIndex,
|
|
1219
|
+
needBytes: literalDataEnd,
|
|
1220
|
+
segments,
|
|
1221
|
+
textStart: literalDataEnd,
|
|
1222
|
+
pendingLiteral: {
|
|
1223
|
+
start: literalDataStart,
|
|
1224
|
+
end: literalDataEnd
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
segments.push({
|
|
1228
|
+
kind: "literal",
|
|
1229
|
+
bytes: buffer.slice(literalDataStart, literalDataEnd)
|
|
1230
|
+
});
|
|
1231
|
+
scanOffset = literalDataEnd;
|
|
1232
|
+
textStart = literalDataEnd;
|
|
1233
|
+
} else {
|
|
1234
|
+
if (crlfIndex >= textStart) segments.push({
|
|
1235
|
+
kind: "text",
|
|
1236
|
+
bytes: buffer.slice(textStart, crlfIndex)
|
|
1237
|
+
});
|
|
1238
|
+
const consumedBytes = crlfIndex + 2;
|
|
1239
|
+
let rawText = "";
|
|
1240
|
+
for (const seg of segments) if (seg.kind === "text") rawText += asciiToString(seg.bytes);
|
|
1241
|
+
else rawText += `<literal:${seg.bytes.length}B>`;
|
|
1242
|
+
return {
|
|
1243
|
+
status: "complete",
|
|
1244
|
+
line: {
|
|
1245
|
+
segments,
|
|
1246
|
+
rawText
|
|
1247
|
+
},
|
|
1248
|
+
consumedBytes
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return {
|
|
1253
|
+
status: "incomplete",
|
|
1254
|
+
resumeAt: Math.max(textStart, buffer.length - 1),
|
|
1255
|
+
needBytes: buffer.length + 1,
|
|
1256
|
+
segments,
|
|
1257
|
+
textStart
|
|
1258
|
+
};
|
|
1259
|
+
};
|
|
1260
|
+
/**
|
|
1261
|
+
* Tokenizes a text segment into IMAP tokens.
|
|
1262
|
+
*/
|
|
1263
|
+
const tokenizeTextSegment = (bytes, tokens) => {
|
|
1264
|
+
let i = 0;
|
|
1265
|
+
const len = bytes.length;
|
|
1266
|
+
while (i < len) {
|
|
1267
|
+
const byte = bytes[i] ?? 0;
|
|
1268
|
+
if (byte === 32 || byte === 9 || byte === 13 || byte === 10) {
|
|
1269
|
+
i++;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (byte === 40) {
|
|
1273
|
+
tokens.push({ kind: "lparen" });
|
|
1274
|
+
i++;
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (byte === 41) {
|
|
1278
|
+
tokens.push({ kind: "rparen" });
|
|
1279
|
+
i++;
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
if (byte === 91) {
|
|
1283
|
+
tokens.push({ kind: "lbracket" });
|
|
1284
|
+
i++;
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (byte === 93) {
|
|
1288
|
+
tokens.push({ kind: "rbracket" });
|
|
1289
|
+
i++;
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (byte === 34) {
|
|
1293
|
+
i++;
|
|
1294
|
+
let quotedStr = "";
|
|
1295
|
+
let isTerminated = false;
|
|
1296
|
+
while (i < len) {
|
|
1297
|
+
const qb = bytes[i] ?? 0;
|
|
1298
|
+
if (qb === 10 && (i === 0 || bytes[i - 1] !== 13)) return {
|
|
1299
|
+
ok: false,
|
|
1300
|
+
reason: {
|
|
1301
|
+
kind: "protocol",
|
|
1302
|
+
detail: "Bare LF in quoted string"
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
if (qb === 92) {
|
|
1306
|
+
i++;
|
|
1307
|
+
if (i >= len) return {
|
|
1308
|
+
ok: false,
|
|
1309
|
+
reason: {
|
|
1310
|
+
kind: "protocol",
|
|
1311
|
+
detail: "Unterminated escape in quoted string"
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
quotedStr += String.fromCharCode(bytes[i] ?? 0);
|
|
1315
|
+
i++;
|
|
1316
|
+
} else if (qb === 34) {
|
|
1317
|
+
isTerminated = true;
|
|
1318
|
+
i++;
|
|
1319
|
+
break;
|
|
1320
|
+
} else {
|
|
1321
|
+
quotedStr += String.fromCharCode(qb);
|
|
1322
|
+
i++;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (!isTerminated) return {
|
|
1326
|
+
ok: false,
|
|
1327
|
+
reason: {
|
|
1328
|
+
kind: "protocol",
|
|
1329
|
+
detail: "Unterminated quoted string"
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
tokens.push({
|
|
1333
|
+
kind: "quoted",
|
|
1334
|
+
value: quotedStr
|
|
1335
|
+
});
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
if (byte === 43) {
|
|
1339
|
+
const next = i + 1 < len ? bytes[i + 1] ?? 0 : 0;
|
|
1340
|
+
if (!(next !== 0 && next !== 32 && next !== 9 && next !== 40 && next !== 41 && next !== 91 && next !== 93 && next !== 13 && next !== 10)) {
|
|
1341
|
+
tokens.push({ kind: "plus" });
|
|
1342
|
+
i++;
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
const atomStart = i;
|
|
1347
|
+
while (i < len) {
|
|
1348
|
+
const b = bytes[i] ?? 0;
|
|
1349
|
+
if (b === 32 || b === 9 || b === 13 || b === 10 || b === 40 || b === 41 || b === 91 || b === 93 || b === 34) break;
|
|
1350
|
+
i++;
|
|
1351
|
+
}
|
|
1352
|
+
const atomStr = asciiToString(bytes.slice(atomStart, i));
|
|
1353
|
+
if (atomStr.toUpperCase() === "NIL") tokens.push({ kind: "nil" });
|
|
1354
|
+
else if (/^\d+$/.test(atomStr)) {
|
|
1355
|
+
const num = Number.parseInt(atomStr, 10);
|
|
1356
|
+
if (num > MAX_UINT32) tokens.push({
|
|
1357
|
+
kind: "atom",
|
|
1358
|
+
value: atomStr
|
|
1359
|
+
});
|
|
1360
|
+
else tokens.push({
|
|
1361
|
+
kind: "number",
|
|
1362
|
+
value: num
|
|
1363
|
+
});
|
|
1364
|
+
} else tokens.push({
|
|
1365
|
+
kind: "atom",
|
|
1366
|
+
value: atomStr
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
return {
|
|
1370
|
+
ok: true,
|
|
1371
|
+
value: void 0
|
|
1372
|
+
};
|
|
1373
|
+
};
|
|
1374
|
+
/**
|
|
1375
|
+
* Tokenizes a complete LogicalLine into ImapTokens.
|
|
1376
|
+
*/
|
|
1377
|
+
const tokenizeLogicalLine = (line) => {
|
|
1378
|
+
const tokens = [];
|
|
1379
|
+
for (const seg of line.segments) if (seg.kind === "literal") tokens.push({
|
|
1380
|
+
kind: "literal",
|
|
1381
|
+
value: seg.bytes
|
|
1382
|
+
});
|
|
1383
|
+
else {
|
|
1384
|
+
const result = tokenizeTextSegment(seg.bytes, tokens);
|
|
1385
|
+
if (!result.ok) return result;
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
ok: true,
|
|
1389
|
+
value: tokens
|
|
1390
|
+
};
|
|
1391
|
+
};
|
|
1392
|
+
//#endregion
|
|
1393
|
+
//#region src/client.ts
|
|
1394
|
+
/**
|
|
1395
|
+
* The msg-ids out of a `HEADER.FIELDS (REFERENCES)` section: the header unfolded, then every
|
|
1396
|
+
* `<...>` in order. A truncated or absent header is simply fewer ids.
|
|
1397
|
+
*/
|
|
1398
|
+
const parseReferencesHeader = (bytes) => {
|
|
1399
|
+
if (bytes === null) return [];
|
|
1400
|
+
const line = asciiToString(bytes).replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/).find((l) => /^references:/i.test(l));
|
|
1401
|
+
if (line === void 0) return [];
|
|
1402
|
+
return line.slice(line.indexOf(":") + 1).match(/<[^<>\s]+>/g) ?? [];
|
|
1403
|
+
};
|
|
1404
|
+
/**
|
|
1405
|
+
* The summaries in a FETCH response's untagged lines, whether the command asked by uid or by
|
|
1406
|
+
* sequence number: `* <seq> FETCH (UID n …)` carries both either way.
|
|
1407
|
+
*/
|
|
1408
|
+
const summariesFrom = (untagged) => untagged.flatMap((item) => {
|
|
1409
|
+
if (item.kind !== "fetch") return [];
|
|
1410
|
+
let uid = 0;
|
|
1411
|
+
let flags = [];
|
|
1412
|
+
let internalDate = null;
|
|
1413
|
+
let size = null;
|
|
1414
|
+
let envelope = null;
|
|
1415
|
+
let references = [];
|
|
1416
|
+
let gmailThreadId = null;
|
|
1417
|
+
for (const fItem of item.items) if (fItem.kind === "uid") uid = fItem.uid;
|
|
1418
|
+
else if (fItem.kind === "flags") flags = fItem.flags;
|
|
1419
|
+
else if (fItem.kind === "internalDate") internalDate = fItem.date;
|
|
1420
|
+
else if (fItem.kind === "size") size = fItem.size;
|
|
1421
|
+
else if (fItem.kind === "envelope") envelope = fItem.envelope;
|
|
1422
|
+
else if (fItem.kind === "gmailThreadId") gmailThreadId = fItem.id;
|
|
1423
|
+
else if (fItem.kind === "body" && fItem.section.toUpperCase().startsWith("HEADER.FIELDS")) references = parseReferencesHeader(fItem.bytes);
|
|
1424
|
+
return [{
|
|
1425
|
+
seq: item.seq,
|
|
1426
|
+
uid,
|
|
1427
|
+
flags,
|
|
1428
|
+
internalDate,
|
|
1429
|
+
size,
|
|
1430
|
+
envelope,
|
|
1431
|
+
references,
|
|
1432
|
+
gmailThreadId
|
|
1433
|
+
}];
|
|
1434
|
+
});
|
|
1435
|
+
const createImapClient = (transport, options) => {
|
|
1436
|
+
const maxLiteralBytes = options?.maxLiteralBytes ?? 33554432;
|
|
1437
|
+
const onUntagged = options?.onUntagged;
|
|
1438
|
+
let chunks = [];
|
|
1439
|
+
let totalBufferedBytes = 0;
|
|
1440
|
+
let resumeState;
|
|
1441
|
+
let isClosed = false;
|
|
1442
|
+
let failureReason = null;
|
|
1443
|
+
let knownCapabilities = [];
|
|
1444
|
+
let tagCounter = 1;
|
|
1445
|
+
const nextTag = () => `A${String(tagCounter++).padStart(4, "0")}`;
|
|
1446
|
+
/**
|
|
1447
|
+
* Reads and parses the next complete IMAP response from the transport.
|
|
1448
|
+
*/
|
|
1449
|
+
const readNextResponse = async () => {
|
|
1450
|
+
if (failureReason !== null) return {
|
|
1451
|
+
ok: false,
|
|
1452
|
+
reason: failureReason
|
|
1453
|
+
};
|
|
1454
|
+
if (isClosed) return {
|
|
1455
|
+
ok: false,
|
|
1456
|
+
reason: { kind: "closed" }
|
|
1457
|
+
};
|
|
1458
|
+
while (true) {
|
|
1459
|
+
if (resumeState === void 0 || totalBufferedBytes >= resumeState.needBytes) {
|
|
1460
|
+
const buffer = chunks.length === 1 && chunks[0] !== void 0 ? chunks[0] : concatByteArrays(chunks);
|
|
1461
|
+
const lineResult = readLogicalLine(buffer, maxLiteralBytes, resumeState);
|
|
1462
|
+
if (lineResult.status === "failure") {
|
|
1463
|
+
isClosed = true;
|
|
1464
|
+
failureReason = lineResult.failure;
|
|
1465
|
+
return {
|
|
1466
|
+
ok: false,
|
|
1467
|
+
reason: lineResult.failure
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
if (lineResult.status === "complete") {
|
|
1471
|
+
const remainingBytes = buffer.length - lineResult.consumedBytes;
|
|
1472
|
+
if (remainingBytes > 0) {
|
|
1473
|
+
chunks = [buffer.slice(lineResult.consumedBytes)];
|
|
1474
|
+
totalBufferedBytes = remainingBytes;
|
|
1475
|
+
} else {
|
|
1476
|
+
chunks = [];
|
|
1477
|
+
totalBufferedBytes = 0;
|
|
1478
|
+
}
|
|
1479
|
+
resumeState = void 0;
|
|
1480
|
+
const tokenResult = tokenizeLogicalLine(lineResult.line);
|
|
1481
|
+
if (!tokenResult.ok) {
|
|
1482
|
+
isClosed = true;
|
|
1483
|
+
failureReason = tokenResult.reason;
|
|
1484
|
+
return {
|
|
1485
|
+
ok: false,
|
|
1486
|
+
reason: tokenResult.reason
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
const parseResult = parseResponse(tokenResult.value);
|
|
1490
|
+
if (!parseResult.ok) {
|
|
1491
|
+
isClosed = true;
|
|
1492
|
+
failureReason = parseResult.reason;
|
|
1493
|
+
return {
|
|
1494
|
+
ok: false,
|
|
1495
|
+
reason: parseResult.reason
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
return parseResult;
|
|
1499
|
+
}
|
|
1500
|
+
resumeState = lineResult;
|
|
1501
|
+
}
|
|
1502
|
+
const chunk = await transport.read().catch(() => null);
|
|
1503
|
+
if (chunk === null) {
|
|
1504
|
+
isClosed = true;
|
|
1505
|
+
failureReason = { kind: "closed" };
|
|
1506
|
+
return {
|
|
1507
|
+
ok: false,
|
|
1508
|
+
reason: { kind: "closed" }
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
chunks.push(chunk);
|
|
1512
|
+
totalBufferedBytes += chunk.length;
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
/**
|
|
1516
|
+
* Greeting is captured immediately as soon as client is created.
|
|
1517
|
+
*/
|
|
1518
|
+
const greetingPromise = (async () => {
|
|
1519
|
+
const res = await readNextResponse();
|
|
1520
|
+
if (!res.ok) return res;
|
|
1521
|
+
if (res.value.kind === "untagged" && res.value.untagged.kind === "status") {
|
|
1522
|
+
const status = res.value.untagged.status;
|
|
1523
|
+
if (status === "OK" || status === "PREAUTH") {
|
|
1524
|
+
const code = res.value.untagged.code;
|
|
1525
|
+
const caps = code !== null && code.kind === "capability" ? code.capabilities : null;
|
|
1526
|
+
if (caps !== null) knownCapabilities = [...caps];
|
|
1527
|
+
return {
|
|
1528
|
+
ok: true,
|
|
1529
|
+
value: {
|
|
1530
|
+
text: res.value.untagged.text,
|
|
1531
|
+
capabilities: caps
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
if (status === "BYE") {
|
|
1536
|
+
const reason = {
|
|
1537
|
+
kind: "bye",
|
|
1538
|
+
text: res.value.untagged.text
|
|
1539
|
+
};
|
|
1540
|
+
isClosed = true;
|
|
1541
|
+
failureReason = reason;
|
|
1542
|
+
return {
|
|
1543
|
+
ok: false,
|
|
1544
|
+
reason
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return {
|
|
1549
|
+
ok: false,
|
|
1550
|
+
reason: {
|
|
1551
|
+
kind: "protocol",
|
|
1552
|
+
detail: "Invalid IMAP greeting received from server"
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
})();
|
|
1556
|
+
let commandQueue = Promise.resolve();
|
|
1557
|
+
const enqueueCommand = (task) => {
|
|
1558
|
+
const result = commandQueue.then(task);
|
|
1559
|
+
commandQueue = result.then(() => void 0, () => void 0);
|
|
1560
|
+
return result.catch(() => ({
|
|
1561
|
+
ok: false,
|
|
1562
|
+
reason: { kind: "closed" }
|
|
1563
|
+
}));
|
|
1564
|
+
};
|
|
1565
|
+
/**
|
|
1566
|
+
* Sends a command and collects untagged responses until matching tagged response.
|
|
1567
|
+
*/
|
|
1568
|
+
const executeCommand = async (build, options) => {
|
|
1569
|
+
if (isClosed) return {
|
|
1570
|
+
ok: false,
|
|
1571
|
+
reason: { kind: "closed" }
|
|
1572
|
+
};
|
|
1573
|
+
if (failureReason !== null) return {
|
|
1574
|
+
ok: false,
|
|
1575
|
+
reason: failureReason.kind === "protocol" ? { kind: "closed" } : failureReason
|
|
1576
|
+
};
|
|
1577
|
+
const greetRes = await greetingPromise;
|
|
1578
|
+
if (!greetRes.ok) return greetRes;
|
|
1579
|
+
const tag = nextTag();
|
|
1580
|
+
const command = build(tag);
|
|
1581
|
+
const hasLiteralPlus = knownCapabilities.some((c) => c.toUpperCase() === "LITERAL+");
|
|
1582
|
+
const hasLiteralMinus = knownCapabilities.some((c) => c.toUpperCase() === "LITERAL-");
|
|
1583
|
+
const untaggedList = [];
|
|
1584
|
+
let seenCapability = false;
|
|
1585
|
+
let allowedByeReason = null;
|
|
1586
|
+
for (const line of command.lines) {
|
|
1587
|
+
try {
|
|
1588
|
+
await transport.write(line.text);
|
|
1589
|
+
} catch {
|
|
1590
|
+
isClosed = true;
|
|
1591
|
+
failureReason = { kind: "closed" };
|
|
1592
|
+
return {
|
|
1593
|
+
ok: false,
|
|
1594
|
+
reason: { kind: "closed" }
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
if (line.literal !== void 0) {
|
|
1598
|
+
const len = line.literal.length;
|
|
1599
|
+
if (hasLiteralPlus || hasLiteralMinus && len <= 4096) try {
|
|
1600
|
+
await transport.write(stringToBytes(`{${len}+}\r\n`));
|
|
1601
|
+
await transport.write(line.literal);
|
|
1602
|
+
} catch {
|
|
1603
|
+
isClosed = true;
|
|
1604
|
+
failureReason = { kind: "closed" };
|
|
1605
|
+
return {
|
|
1606
|
+
ok: false,
|
|
1607
|
+
reason: { kind: "closed" }
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
else {
|
|
1611
|
+
try {
|
|
1612
|
+
await transport.write(stringToBytes(`{${len}}\r\n`));
|
|
1613
|
+
} catch {
|
|
1614
|
+
isClosed = true;
|
|
1615
|
+
failureReason = { kind: "closed" };
|
|
1616
|
+
return {
|
|
1617
|
+
ok: false,
|
|
1618
|
+
reason: { kind: "closed" }
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
let continuationReceived = false;
|
|
1622
|
+
while (!continuationReceived) {
|
|
1623
|
+
const respResult = await readNextResponse();
|
|
1624
|
+
if (!respResult.ok) {
|
|
1625
|
+
if (respResult.reason.kind === "closed" && allowedByeReason !== null) return {
|
|
1626
|
+
ok: false,
|
|
1627
|
+
reason: allowedByeReason
|
|
1628
|
+
};
|
|
1629
|
+
return respResult;
|
|
1630
|
+
}
|
|
1631
|
+
const resp = respResult.value;
|
|
1632
|
+
if (resp.kind === "untagged") {
|
|
1633
|
+
untaggedList.push(resp.untagged);
|
|
1634
|
+
onUntagged?.(resp.untagged);
|
|
1635
|
+
if (resp.untagged.kind === "capability") {
|
|
1636
|
+
knownCapabilities = [...resp.untagged.capabilities];
|
|
1637
|
+
seenCapability = true;
|
|
1638
|
+
}
|
|
1639
|
+
if (resp.untagged.kind === "status" && resp.untagged.status === "BYE") {
|
|
1640
|
+
if (options?.allowBye !== true) {
|
|
1641
|
+
const reason = {
|
|
1642
|
+
kind: "bye",
|
|
1643
|
+
text: resp.untagged.text
|
|
1644
|
+
};
|
|
1645
|
+
isClosed = true;
|
|
1646
|
+
failureReason = reason;
|
|
1647
|
+
return {
|
|
1648
|
+
ok: false,
|
|
1649
|
+
reason
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
allowedByeReason = {
|
|
1653
|
+
kind: "bye",
|
|
1654
|
+
text: resp.untagged.text
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
} else if (resp.kind === "continuation") {
|
|
1658
|
+
continuationReceived = true;
|
|
1659
|
+
try {
|
|
1660
|
+
await transport.write(line.literal);
|
|
1661
|
+
} catch {
|
|
1662
|
+
isClosed = true;
|
|
1663
|
+
failureReason = { kind: "closed" };
|
|
1664
|
+
return {
|
|
1665
|
+
ok: false,
|
|
1666
|
+
reason: { kind: "closed" }
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
} else if (resp.kind === "tagged") {
|
|
1670
|
+
if (resp.tag !== tag) {
|
|
1671
|
+
isClosed = true;
|
|
1672
|
+
failureReason = {
|
|
1673
|
+
kind: "protocol",
|
|
1674
|
+
detail: `Received unexpected tag ${resp.tag}, expected ${tag}`
|
|
1675
|
+
};
|
|
1676
|
+
return {
|
|
1677
|
+
ok: false,
|
|
1678
|
+
reason: failureReason
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
if (resp.status === "NO") return {
|
|
1682
|
+
ok: false,
|
|
1683
|
+
reason: {
|
|
1684
|
+
kind: "no",
|
|
1685
|
+
text: resp.text
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1688
|
+
if (resp.status === "BAD") return {
|
|
1689
|
+
ok: false,
|
|
1690
|
+
reason: {
|
|
1691
|
+
kind: "bad",
|
|
1692
|
+
text: resp.text
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
while (true) {
|
|
1701
|
+
const respResult = await readNextResponse();
|
|
1702
|
+
if (!respResult.ok) {
|
|
1703
|
+
if (respResult.reason.kind === "closed" && allowedByeReason !== null) return {
|
|
1704
|
+
ok: false,
|
|
1705
|
+
reason: allowedByeReason
|
|
1706
|
+
};
|
|
1707
|
+
return respResult;
|
|
1708
|
+
}
|
|
1709
|
+
const resp = respResult.value;
|
|
1710
|
+
if (resp.kind === "untagged") {
|
|
1711
|
+
untaggedList.push(resp.untagged);
|
|
1712
|
+
onUntagged?.(resp.untagged);
|
|
1713
|
+
if (resp.untagged.kind === "capability") {
|
|
1714
|
+
knownCapabilities = [...resp.untagged.capabilities];
|
|
1715
|
+
seenCapability = true;
|
|
1716
|
+
}
|
|
1717
|
+
if (resp.untagged.kind === "status" && resp.untagged.status === "BYE") {
|
|
1718
|
+
if (options?.allowBye !== true) {
|
|
1719
|
+
const reason = {
|
|
1720
|
+
kind: "bye",
|
|
1721
|
+
text: resp.untagged.text
|
|
1722
|
+
};
|
|
1723
|
+
isClosed = true;
|
|
1724
|
+
failureReason = reason;
|
|
1725
|
+
return {
|
|
1726
|
+
ok: false,
|
|
1727
|
+
reason
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
allowedByeReason = {
|
|
1731
|
+
kind: "bye",
|
|
1732
|
+
text: resp.untagged.text
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
} else if (resp.kind === "continuation") if (options?.onContinuation !== void 0) {
|
|
1736
|
+
const contRes = await options.onContinuation();
|
|
1737
|
+
if (!contRes.ok) return contRes;
|
|
1738
|
+
} else {
|
|
1739
|
+
isClosed = true;
|
|
1740
|
+
failureReason = {
|
|
1741
|
+
kind: "protocol",
|
|
1742
|
+
detail: "Unexpected continuation response from server"
|
|
1743
|
+
};
|
|
1744
|
+
return {
|
|
1745
|
+
ok: false,
|
|
1746
|
+
reason: failureReason
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
else if (resp.kind === "tagged") {
|
|
1750
|
+
if (resp.tag !== tag) {
|
|
1751
|
+
isClosed = true;
|
|
1752
|
+
failureReason = {
|
|
1753
|
+
kind: "protocol",
|
|
1754
|
+
detail: `Received unexpected tag ${resp.tag}, expected ${tag}`
|
|
1755
|
+
};
|
|
1756
|
+
return {
|
|
1757
|
+
ok: false,
|
|
1758
|
+
reason: failureReason
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
if (resp.code !== null && resp.code.kind === "capability") {
|
|
1762
|
+
knownCapabilities = [...resp.code.capabilities];
|
|
1763
|
+
seenCapability = true;
|
|
1764
|
+
}
|
|
1765
|
+
if (resp.status === "OK") return {
|
|
1766
|
+
ok: true,
|
|
1767
|
+
value: {
|
|
1768
|
+
tagged: resp,
|
|
1769
|
+
untagged: untaggedList,
|
|
1770
|
+
seenCapability
|
|
1771
|
+
}
|
|
1772
|
+
};
|
|
1773
|
+
if (resp.status === "NO") return {
|
|
1774
|
+
ok: false,
|
|
1775
|
+
reason: {
|
|
1776
|
+
kind: "no",
|
|
1777
|
+
text: resp.text
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
if (resp.status === "BAD") return {
|
|
1781
|
+
ok: false,
|
|
1782
|
+
reason: {
|
|
1783
|
+
kind: "bad",
|
|
1784
|
+
text: resp.text
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
return {
|
|
1791
|
+
greeting: () => greetingPromise,
|
|
1792
|
+
capability: () => enqueueCommand(async () => {
|
|
1793
|
+
const res = await executeCommand(buildCapabilityCommand);
|
|
1794
|
+
if (!res.ok) return res;
|
|
1795
|
+
return {
|
|
1796
|
+
ok: true,
|
|
1797
|
+
value: [...knownCapabilities]
|
|
1798
|
+
};
|
|
1799
|
+
}),
|
|
1800
|
+
capabilities: () => [...knownCapabilities],
|
|
1801
|
+
hasCapability: (name) => knownCapabilities.some((c) => c.toUpperCase() === name.toUpperCase()),
|
|
1802
|
+
authenticate: (username, password) => enqueueCommand(async () => {
|
|
1803
|
+
const greetRes = await greetingPromise;
|
|
1804
|
+
if (!greetRes.ok) return greetRes;
|
|
1805
|
+
if (knownCapabilities.length === 0) {
|
|
1806
|
+
const capRes = await executeCommand(buildCapabilityCommand);
|
|
1807
|
+
if (!capRes.ok) return capRes;
|
|
1808
|
+
}
|
|
1809
|
+
const hasAuthPlain = knownCapabilities.some((c) => c.toUpperCase() === "AUTH=PLAIN" || c.toUpperCase() === "AUTHENTICATE=PLAIN");
|
|
1810
|
+
const hasSaslIr = knownCapabilities.some((c) => c.toUpperCase() === "SASL-IR");
|
|
1811
|
+
const hasLoginDisabled = knownCapabilities.some((c) => c.toUpperCase() === "LOGINDISABLED");
|
|
1812
|
+
let authCmdRes;
|
|
1813
|
+
if (hasAuthPlain) if (hasSaslIr) authCmdRes = await executeCommand((tag) => buildAuthenticatePlainSaslIrCommand(tag, username, password));
|
|
1814
|
+
else authCmdRes = await executeCommand((tag) => buildAuthenticatePlainInitialCommand(tag), { onContinuation: async () => {
|
|
1815
|
+
try {
|
|
1816
|
+
const respCmd = buildAuthenticatePlainResponse(username, password);
|
|
1817
|
+
for (const line of respCmd.lines) await transport.write(line.text);
|
|
1818
|
+
return {
|
|
1819
|
+
ok: true,
|
|
1820
|
+
value: void 0
|
|
1821
|
+
};
|
|
1822
|
+
} catch {
|
|
1823
|
+
isClosed = true;
|
|
1824
|
+
failureReason = { kind: "closed" };
|
|
1825
|
+
return {
|
|
1826
|
+
ok: false,
|
|
1827
|
+
reason: { kind: "closed" }
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
} });
|
|
1831
|
+
else if (!hasLoginDisabled) authCmdRes = await executeCommand((tag) => buildLoginCommand(tag, username, password));
|
|
1832
|
+
else return {
|
|
1833
|
+
ok: false,
|
|
1834
|
+
reason: {
|
|
1835
|
+
kind: "unsupported",
|
|
1836
|
+
detail: "Server does not support AUTH=PLAIN and LOGIN is disabled"
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
if (!authCmdRes.ok) return authCmdRes;
|
|
1840
|
+
if (!authCmdRes.value.seenCapability) {
|
|
1841
|
+
const postAuthCapRes = await executeCommand(buildCapabilityCommand);
|
|
1842
|
+
if (!postAuthCapRes.ok) return postAuthCapRes;
|
|
1843
|
+
}
|
|
1844
|
+
return {
|
|
1845
|
+
ok: true,
|
|
1846
|
+
value: void 0
|
|
1847
|
+
};
|
|
1848
|
+
}),
|
|
1849
|
+
list: (reference, pattern) => enqueueCommand(async () => {
|
|
1850
|
+
const res = await executeCommand((tag) => buildListCommand(tag, reference, pattern));
|
|
1851
|
+
if (!res.ok) return res;
|
|
1852
|
+
const mailboxes = [];
|
|
1853
|
+
for (const item of res.value.untagged) if (item.kind === "list") mailboxes.push(item.mailbox);
|
|
1854
|
+
return {
|
|
1855
|
+
ok: true,
|
|
1856
|
+
value: mailboxes
|
|
1857
|
+
};
|
|
1858
|
+
}),
|
|
1859
|
+
select: (mailbox, options) => enqueueCommand(async () => {
|
|
1860
|
+
const isReadOnlyRequested = options?.readOnly ?? false;
|
|
1861
|
+
const res = await executeCommand((tag) => buildSelectCommand(tag, mailbox, isReadOnlyRequested));
|
|
1862
|
+
if (!res.ok) return res;
|
|
1863
|
+
let exists = 0;
|
|
1864
|
+
let flags = [];
|
|
1865
|
+
let permanentFlags = [];
|
|
1866
|
+
let uidValidity = null;
|
|
1867
|
+
let uidNext = null;
|
|
1868
|
+
let isReadOnly = isReadOnlyRequested;
|
|
1869
|
+
const applyCode = (code) => {
|
|
1870
|
+
if (code === null) return;
|
|
1871
|
+
if (code.kind === "uidValidity") uidValidity = code.value;
|
|
1872
|
+
else if (code.kind === "uidNext") uidNext = code.value;
|
|
1873
|
+
else if (code.kind === "permanentFlags") permanentFlags = [...code.flags];
|
|
1874
|
+
else if (code.kind === "readOnly") isReadOnly = true;
|
|
1875
|
+
else if (code.kind === "readWrite") isReadOnly = false;
|
|
1876
|
+
};
|
|
1877
|
+
for (const item of res.value.untagged) if (item.kind === "exists") exists = item.count;
|
|
1878
|
+
else if (item.kind === "flags") flags = [...item.flags];
|
|
1879
|
+
else if (item.kind === "status") applyCode(item.code);
|
|
1880
|
+
applyCode(res.value.tagged.code);
|
|
1881
|
+
return {
|
|
1882
|
+
ok: true,
|
|
1883
|
+
value: {
|
|
1884
|
+
name: mailbox,
|
|
1885
|
+
exists,
|
|
1886
|
+
uidValidity,
|
|
1887
|
+
uidNext,
|
|
1888
|
+
flags,
|
|
1889
|
+
permanentFlags,
|
|
1890
|
+
readOnly: isReadOnly
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
}),
|
|
1894
|
+
fetchSummaries: (uidSet) => enqueueCommand(async () => {
|
|
1895
|
+
const gmail = knownCapabilities.some((c) => c.toUpperCase() === "X-GM-EXT-1");
|
|
1896
|
+
const res = await executeCommand((tag) => buildFetchSummariesCommand(tag, uidSet, { gmail }));
|
|
1897
|
+
if (!res.ok) return res;
|
|
1898
|
+
return {
|
|
1899
|
+
ok: true,
|
|
1900
|
+
value: summariesFrom(res.value.untagged)
|
|
1901
|
+
};
|
|
1902
|
+
}),
|
|
1903
|
+
fetchSummariesBySeq: (seqSet) => enqueueCommand(async () => {
|
|
1904
|
+
const gmail = knownCapabilities.some((c) => c.toUpperCase() === "X-GM-EXT-1");
|
|
1905
|
+
const res = await executeCommand((tag) => buildFetchSummariesCommand(tag, seqSet, {
|
|
1906
|
+
gmail,
|
|
1907
|
+
bySeq: true
|
|
1908
|
+
}));
|
|
1909
|
+
if (!res.ok) return res;
|
|
1910
|
+
return {
|
|
1911
|
+
ok: true,
|
|
1912
|
+
value: summariesFrom(res.value.untagged)
|
|
1913
|
+
};
|
|
1914
|
+
}),
|
|
1915
|
+
fetchFlags: (uidSet) => enqueueCommand(async () => {
|
|
1916
|
+
const res = await executeCommand((tag) => buildFetchFlagsCommand(tag, uidSet));
|
|
1917
|
+
if (!res.ok) return res;
|
|
1918
|
+
const result = [];
|
|
1919
|
+
for (const item of res.value.untagged) {
|
|
1920
|
+
if (item.kind !== "fetch") continue;
|
|
1921
|
+
let uid = 0;
|
|
1922
|
+
let flags = [];
|
|
1923
|
+
for (const fItem of item.items) if (fItem.kind === "uid") uid = fItem.uid;
|
|
1924
|
+
else if (fItem.kind === "flags") flags = fItem.flags;
|
|
1925
|
+
if (uid !== 0) result.push({
|
|
1926
|
+
uid,
|
|
1927
|
+
flags
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
return {
|
|
1931
|
+
ok: true,
|
|
1932
|
+
value: result
|
|
1933
|
+
};
|
|
1934
|
+
}),
|
|
1935
|
+
fetchRaw: (uid) => enqueueCommand(async () => {
|
|
1936
|
+
const res = await executeCommand((tag) => buildFetchRawCommand(tag, uid));
|
|
1937
|
+
if (!res.ok) return res;
|
|
1938
|
+
for (const item of res.value.untagged) if (item.kind === "fetch") {
|
|
1939
|
+
for (const fItem of item.items) if (fItem.kind === "body" && fItem.bytes !== null) return {
|
|
1940
|
+
ok: true,
|
|
1941
|
+
value: fItem.bytes
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
return {
|
|
1945
|
+
ok: false,
|
|
1946
|
+
reason: {
|
|
1947
|
+
kind: "no",
|
|
1948
|
+
text: "Raw message body not found"
|
|
1949
|
+
}
|
|
1950
|
+
};
|
|
1951
|
+
}),
|
|
1952
|
+
storeFlags: (uidSet, mode, flags) => enqueueCommand(async () => {
|
|
1953
|
+
const res = await executeCommand((tag) => buildStoreFlagsCommand(tag, uidSet, mode, flags));
|
|
1954
|
+
if (!res.ok) return res;
|
|
1955
|
+
return {
|
|
1956
|
+
ok: true,
|
|
1957
|
+
value: void 0
|
|
1958
|
+
};
|
|
1959
|
+
}),
|
|
1960
|
+
append: (mailbox, message, flags) => enqueueCommand(async () => {
|
|
1961
|
+
const res = await executeCommand((tag) => buildAppendCommand(tag, mailbox, flags, message));
|
|
1962
|
+
if (!res.ok) return res;
|
|
1963
|
+
return {
|
|
1964
|
+
ok: true,
|
|
1965
|
+
value: void 0
|
|
1966
|
+
};
|
|
1967
|
+
}),
|
|
1968
|
+
move: (uidSet, mailbox) => enqueueCommand(async () => {
|
|
1969
|
+
const greetRes = await greetingPromise;
|
|
1970
|
+
if (!greetRes.ok) return greetRes;
|
|
1971
|
+
if (!knownCapabilities.some((c) => {
|
|
1972
|
+
const name = c.toUpperCase();
|
|
1973
|
+
return name === "MOVE" || name === "IMAP4REV2";
|
|
1974
|
+
})) return {
|
|
1975
|
+
ok: false,
|
|
1976
|
+
reason: {
|
|
1977
|
+
kind: "no",
|
|
1978
|
+
text: "MOVE is not supported by this server"
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
const res = await executeCommand((tag) => buildMoveCommand(tag, uidSet, mailbox));
|
|
1982
|
+
if (!res.ok) return res;
|
|
1983
|
+
return {
|
|
1984
|
+
ok: true,
|
|
1985
|
+
value: void 0
|
|
1986
|
+
};
|
|
1987
|
+
}),
|
|
1988
|
+
create: (mailbox) => enqueueCommand(async () => {
|
|
1989
|
+
const res = await executeCommand((tag) => buildCreateCommand(tag, mailbox));
|
|
1990
|
+
if (!res.ok) return res;
|
|
1991
|
+
return {
|
|
1992
|
+
ok: true,
|
|
1993
|
+
value: void 0
|
|
1994
|
+
};
|
|
1995
|
+
}),
|
|
1996
|
+
noop: () => enqueueCommand(async () => {
|
|
1997
|
+
const res = await executeCommand(buildNoopCommand);
|
|
1998
|
+
if (!res.ok) return res;
|
|
1999
|
+
return {
|
|
2000
|
+
ok: true,
|
|
2001
|
+
value: void 0
|
|
2002
|
+
};
|
|
2003
|
+
}),
|
|
2004
|
+
idle: () => {
|
|
2005
|
+
if (isClosed || failureReason !== null) {
|
|
2006
|
+
const ended = Promise.resolve({
|
|
2007
|
+
ok: false,
|
|
2008
|
+
reason: { kind: "closed" }
|
|
2009
|
+
});
|
|
2010
|
+
return {
|
|
2011
|
+
done: async () => ended,
|
|
2012
|
+
ended
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
let doneRequested = false;
|
|
2016
|
+
let doneSent = false;
|
|
2017
|
+
/** True once '+' arrived; DONE may be written (including from `done()` while a read waits). */
|
|
2018
|
+
let isIdling = false;
|
|
2019
|
+
/** True once the IDLE has settled either way; a `done()` after that has nothing to end. */
|
|
2020
|
+
let isEnded = false;
|
|
2021
|
+
const sendDone = async () => {
|
|
2022
|
+
if (doneSent || !isIdling || isEnded) return null;
|
|
2023
|
+
doneSent = true;
|
|
2024
|
+
try {
|
|
2025
|
+
await transport.write(buildIdleDoneLine());
|
|
2026
|
+
return null;
|
|
2027
|
+
} catch {
|
|
2028
|
+
isClosed = true;
|
|
2029
|
+
failureReason = { kind: "closed" };
|
|
2030
|
+
return {
|
|
2031
|
+
ok: false,
|
|
2032
|
+
reason: { kind: "closed" }
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
const ended = enqueueCommand(async () => {
|
|
2037
|
+
if (failureReason !== null) return {
|
|
2038
|
+
ok: false,
|
|
2039
|
+
reason: failureReason.kind === "protocol" ? { kind: "closed" } : failureReason
|
|
2040
|
+
};
|
|
2041
|
+
if (isClosed) return {
|
|
2042
|
+
ok: false,
|
|
2043
|
+
reason: { kind: "closed" }
|
|
2044
|
+
};
|
|
2045
|
+
const greetRes = await greetingPromise;
|
|
2046
|
+
if (!greetRes.ok) return greetRes;
|
|
2047
|
+
const tag = nextTag();
|
|
2048
|
+
const command = buildIdleCommand(tag);
|
|
2049
|
+
for (const line of command.lines) try {
|
|
2050
|
+
await transport.write(line.text);
|
|
2051
|
+
} catch {
|
|
2052
|
+
isClosed = true;
|
|
2053
|
+
failureReason = { kind: "closed" };
|
|
2054
|
+
return {
|
|
2055
|
+
ok: false,
|
|
2056
|
+
reason: { kind: "closed" }
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
while (true) {
|
|
2060
|
+
const respResult = await readNextResponse();
|
|
2061
|
+
if (!respResult.ok) return respResult;
|
|
2062
|
+
const resp = respResult.value;
|
|
2063
|
+
if (resp.kind === "untagged") {
|
|
2064
|
+
onUntagged?.(resp.untagged);
|
|
2065
|
+
if (resp.untagged.kind === "status" && resp.untagged.status === "BYE") {
|
|
2066
|
+
const reason = {
|
|
2067
|
+
kind: "bye",
|
|
2068
|
+
text: resp.untagged.text
|
|
2069
|
+
};
|
|
2070
|
+
isClosed = true;
|
|
2071
|
+
failureReason = reason;
|
|
2072
|
+
return {
|
|
2073
|
+
ok: false,
|
|
2074
|
+
reason
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (resp.kind === "continuation") break;
|
|
2080
|
+
if (resp.kind === "tagged") {
|
|
2081
|
+
if (resp.tag !== tag) {
|
|
2082
|
+
isClosed = true;
|
|
2083
|
+
failureReason = {
|
|
2084
|
+
kind: "protocol",
|
|
2085
|
+
detail: `Received unexpected tag ${resp.tag}, expected ${tag}`
|
|
2086
|
+
};
|
|
2087
|
+
return {
|
|
2088
|
+
ok: false,
|
|
2089
|
+
reason: failureReason
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
if (resp.status === "NO") return {
|
|
2093
|
+
ok: false,
|
|
2094
|
+
reason: {
|
|
2095
|
+
kind: "no",
|
|
2096
|
+
text: resp.text
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
if (resp.status === "BAD") return {
|
|
2100
|
+
ok: false,
|
|
2101
|
+
reason: {
|
|
2102
|
+
kind: "bad",
|
|
2103
|
+
text: resp.text
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
isClosed = true;
|
|
2107
|
+
failureReason = {
|
|
2108
|
+
kind: "protocol",
|
|
2109
|
+
detail: "IDLE completed without a continuation"
|
|
2110
|
+
};
|
|
2111
|
+
return {
|
|
2112
|
+
ok: false,
|
|
2113
|
+
reason: failureReason
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
isIdling = true;
|
|
2118
|
+
if (doneRequested) {
|
|
2119
|
+
const sendFail = await sendDone();
|
|
2120
|
+
if (sendFail !== null) return sendFail;
|
|
2121
|
+
}
|
|
2122
|
+
while (true) {
|
|
2123
|
+
const respResult = await readNextResponse();
|
|
2124
|
+
if (!respResult.ok) return respResult;
|
|
2125
|
+
const resp = respResult.value;
|
|
2126
|
+
if (resp.kind === "untagged") {
|
|
2127
|
+
onUntagged?.(resp.untagged);
|
|
2128
|
+
if (resp.untagged.kind === "status" && resp.untagged.status === "BYE") {
|
|
2129
|
+
const reason = {
|
|
2130
|
+
kind: "bye",
|
|
2131
|
+
text: resp.untagged.text
|
|
2132
|
+
};
|
|
2133
|
+
isClosed = true;
|
|
2134
|
+
failureReason = reason;
|
|
2135
|
+
return {
|
|
2136
|
+
ok: false,
|
|
2137
|
+
reason
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
continue;
|
|
2141
|
+
}
|
|
2142
|
+
if (resp.kind === "continuation") {
|
|
2143
|
+
isClosed = true;
|
|
2144
|
+
failureReason = {
|
|
2145
|
+
kind: "protocol",
|
|
2146
|
+
detail: "Unexpected continuation while IDLE"
|
|
2147
|
+
};
|
|
2148
|
+
return {
|
|
2149
|
+
ok: false,
|
|
2150
|
+
reason: failureReason
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
if (resp.kind === "tagged") {
|
|
2154
|
+
if (resp.tag !== tag) {
|
|
2155
|
+
isClosed = true;
|
|
2156
|
+
failureReason = {
|
|
2157
|
+
kind: "protocol",
|
|
2158
|
+
detail: `Received unexpected tag ${resp.tag}, expected ${tag}`
|
|
2159
|
+
};
|
|
2160
|
+
return {
|
|
2161
|
+
ok: false,
|
|
2162
|
+
reason: failureReason
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
if (!doneSent) {
|
|
2166
|
+
isClosed = true;
|
|
2167
|
+
failureReason = {
|
|
2168
|
+
kind: "protocol",
|
|
2169
|
+
detail: "Tagged response while IDLE before DONE"
|
|
2170
|
+
};
|
|
2171
|
+
return {
|
|
2172
|
+
ok: false,
|
|
2173
|
+
reason: failureReason
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
if (resp.status === "OK") return {
|
|
2177
|
+
ok: true,
|
|
2178
|
+
value: void 0
|
|
2179
|
+
};
|
|
2180
|
+
if (resp.status === "NO") return {
|
|
2181
|
+
ok: false,
|
|
2182
|
+
reason: {
|
|
2183
|
+
kind: "no",
|
|
2184
|
+
text: resp.text
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
if (resp.status === "BAD") return {
|
|
2188
|
+
ok: false,
|
|
2189
|
+
reason: {
|
|
2190
|
+
kind: "bad",
|
|
2191
|
+
text: resp.text
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
ended.finally(() => {
|
|
2198
|
+
isEnded = true;
|
|
2199
|
+
});
|
|
2200
|
+
return {
|
|
2201
|
+
done: async () => {
|
|
2202
|
+
doneRequested = true;
|
|
2203
|
+
await sendDone();
|
|
2204
|
+
return ended;
|
|
2205
|
+
},
|
|
2206
|
+
ended
|
|
2207
|
+
};
|
|
2208
|
+
},
|
|
2209
|
+
logout: () => enqueueCommand(async () => {
|
|
2210
|
+
const res = await executeCommand(buildLogoutCommand, { allowBye: true });
|
|
2211
|
+
isClosed = true;
|
|
2212
|
+
if (!res.ok) return res;
|
|
2213
|
+
return {
|
|
2214
|
+
ok: true,
|
|
2215
|
+
value: void 0
|
|
2216
|
+
};
|
|
2217
|
+
})
|
|
2218
|
+
};
|
|
2219
|
+
};
|
|
2220
|
+
//#endregion
|
|
2221
|
+
export { DEFAULT_MAX_LITERAL_BYTES, createImapClient, decodeRfc2047, parseResponse };
|