@yozz.app/imap 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +68 -55
- package/dist/index.mjs +320 -208
- package/package.json +7 -6
package/dist/index.d.mts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
//#region src/tokenizer.d.ts
|
|
2
|
-
|
|
3
|
-
* Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
|
|
4
|
-
*
|
|
5
|
-
* Implements total parsing over attacker-controlled bytes:
|
|
6
|
-
* - Line ends at CRLF; bare LF is a protocol failure.
|
|
7
|
-
* - Logical line frames `{n}` literals (and `{n+}` LITERAL+).
|
|
8
|
-
* - Enforces maxLiteralBytes (default 32 MiB).
|
|
9
|
-
* - A number above 2^32 - 1 is an atom of digits, not a failure: RFC 9051's `number` is
|
|
10
|
-
* 32-bit, but Gmail's X-GM-THRID is 64-bit and arrives as a bare number.
|
|
11
|
-
* - Never throws on malformed input; returns typed ImapResult.
|
|
12
|
-
*/
|
|
13
|
-
declare const DEFAULT_MAX_LITERAL_BYTES: number;
|
|
2
|
+
export declare const DEFAULT_MAX_LITERAL_BYTES: number;
|
|
14
3
|
type ImapToken = {
|
|
15
4
|
readonly kind: 'atom';
|
|
16
5
|
readonly value: string;
|
|
@@ -103,7 +92,9 @@ type ImapFetchItem = {
|
|
|
103
92
|
} | {
|
|
104
93
|
readonly kind: 'bodyStructure';
|
|
105
94
|
readonly parts: readonly string[];
|
|
106
|
-
}
|
|
95
|
+
} |
|
|
96
|
+
/** Gmail's `X-GM-THRID` is 64-bit, so it stays decimal digits. */
|
|
97
|
+
{
|
|
107
98
|
readonly kind: 'gmailThreadId';
|
|
108
99
|
readonly id: string;
|
|
109
100
|
} | {
|
|
@@ -190,14 +181,9 @@ type ImapResponse = ImapTagged | {
|
|
|
190
181
|
readonly kind: 'untagged';
|
|
191
182
|
readonly untagged: ImapUntagged;
|
|
192
183
|
} | ImapContinuation;
|
|
193
|
-
declare const parseResponse: (tokens: readonly ImapToken[]) => ImapResult<ImapResponse>;
|
|
184
|
+
export declare const parseResponse: (tokens: readonly ImapToken[]) => ImapResult<ImapResponse>;
|
|
194
185
|
//#endregion
|
|
195
186
|
//#region src/transport.d.ts
|
|
196
|
-
/**
|
|
197
|
-
* What this client needs from a transport: bytes in, bytes out. Declared here rather than
|
|
198
|
-
* imported from `@yozz.app/tls` so the package has no runtime dependency at all; it is the same
|
|
199
|
-
* two-method shape, so a `TlsConnection` satisfies it structurally.
|
|
200
|
-
*/
|
|
201
187
|
type ByteDuplex = {
|
|
202
188
|
readonly read: () => Promise<Uint8Array | null>;
|
|
203
189
|
readonly write: (bytes: Uint8Array) => Promise<void>;
|
|
@@ -210,8 +196,10 @@ type ImapMessageSummary = {
|
|
|
210
196
|
readonly flags: readonly string[];
|
|
211
197
|
readonly internalDate: string | null;
|
|
212
198
|
readonly size: number | null;
|
|
213
|
-
readonly envelope: ImapEnvelope | null;
|
|
214
|
-
|
|
199
|
+
readonly envelope: ImapEnvelope | null;
|
|
200
|
+
/** The msg-ids of the `References` header, in order; empty when the message carries none. */
|
|
201
|
+
readonly references: readonly string[];
|
|
202
|
+
/** Gmail's thread id (decimal digits), when the server was asked and answered. */
|
|
215
203
|
readonly gmailThreadId: string | null;
|
|
216
204
|
};
|
|
217
205
|
type ImapMessageFlags = {
|
|
@@ -228,11 +216,14 @@ type ImapSelected = {
|
|
|
228
216
|
readonly readOnly: boolean;
|
|
229
217
|
};
|
|
230
218
|
type ImapClientOptions = {
|
|
231
|
-
readonly onUntagged?: (response: ImapUntagged) => void;
|
|
219
|
+
readonly onUntagged?: (response: ImapUntagged) => void;
|
|
220
|
+
/** Default 32 MiB; a larger literal is a protocol failure. */
|
|
232
221
|
readonly maxLiteralBytes?: number;
|
|
233
222
|
};
|
|
234
223
|
type ImapIdle = {
|
|
235
|
-
/** Sends DONE (once; later calls are no-ops) and resolves with the IDLE's outcome. */
|
|
224
|
+
/** Sends DONE (once; later calls are no-ops) and resolves with the IDLE's outcome. */
|
|
225
|
+
readonly done: () => Promise<ImapResult<void>>;
|
|
226
|
+
/** The same outcome, for a caller that wants to observe an idle ending on its own (BYE, EOF). */
|
|
236
227
|
readonly ended: Promise<ImapResult<void>>;
|
|
237
228
|
};
|
|
238
229
|
type ImapClient = {
|
|
@@ -241,54 +232,76 @@ type ImapClient = {
|
|
|
241
232
|
readonly capabilities: readonly string[] | null;
|
|
242
233
|
}>>;
|
|
243
234
|
readonly capability: () => Promise<ImapResult<readonly string[]>>;
|
|
244
|
-
|
|
235
|
+
/** Last known. */
|
|
236
|
+
readonly capabilities: () => readonly string[];
|
|
237
|
+
/** Case-insensitive check against the last known capability list. */
|
|
245
238
|
readonly hasCapability: (name: string) => boolean;
|
|
246
239
|
readonly authenticate: (username: string, password: string) => Promise<ImapResult<void>>;
|
|
247
240
|
readonly list: (reference: string, pattern: string) => Promise<ImapResult<readonly ImapMailbox[]>>;
|
|
248
241
|
readonly select: (mailbox: string, options?: {
|
|
249
242
|
readonly readOnly?: boolean;
|
|
250
243
|
}) => Promise<ImapResult<ImapSelected>>;
|
|
251
|
-
/**
|
|
252
|
-
* UID FETCH with FLAGS ENVELOPE INTERNALDATE RFC822.SIZE, the `References` header and, on a
|
|
253
|
-
* server advertising `X-GM-EXT-1`, `X-GM-THRID`. `set` is an IMAP sequence-set string, e.g.
|
|
254
|
-
* "1:*" or "100:200".
|
|
255
|
-
*/
|
|
244
|
+
/** `uidSet` is an IMAP sequence-set, e.g. "1:*" or "100:200". */
|
|
256
245
|
readonly fetchSummaries: (uidSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>;
|
|
246
|
+
/** By message sequence number (dense, 1..EXISTS) rather than uid (sparse after deletions). */
|
|
247
|
+
readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>;
|
|
248
|
+
readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>;
|
|
249
|
+
readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
|
|
250
|
+
readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
|
|
251
|
+
/** Resolves with the RFC 4315 `APPENDUID`, or `null` from a server without UIDPLUS. */
|
|
252
|
+
readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[], internalDate?: Date) => Promise<ImapResult<AppendUid | null>>;
|
|
253
|
+
readonly expunge: () => Promise<ImapResult<void>>;
|
|
254
|
+
/** RFC 4315. Refuses without UIDPLUS rather than falling back to EXPUNGE, which erases more than asked. */
|
|
255
|
+
readonly uidExpunge: (uidSet: string) => Promise<ImapResult<void>>;
|
|
256
|
+
readonly uidSearchHeader: (header: string, value: string) => Promise<ImapResult<readonly number[]>>;
|
|
257
257
|
/**
|
|
258
|
-
* The
|
|
259
|
-
*
|
|
260
|
-
*
|
|
258
|
+
* The selected mailbox's uids whose subject, sender or body contains `text`, ascending. One
|
|
259
|
+
* search per field and a union: `TEXT` alone is the body on Forward Email's server, and its `OR`
|
|
260
|
+
* answers a subset of what the operands do.
|
|
261
261
|
*/
|
|
262
|
-
readonly
|
|
263
|
-
|
|
264
|
-
readonly
|
|
265
|
-
readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>; /** APPEND a whole RFC 5322 message to a mailbox, e.g. a Sent copy after SMTP accepted it. */
|
|
266
|
-
readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[]) => Promise<ImapResult<void>>; /** RFC 6851 UID MOVE. Refuses without the MOVE capability (no COPY+EXPUNGE fallback). */
|
|
267
|
-
readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<void>>; /** CREATE a mailbox. */
|
|
262
|
+
readonly uidSearchText: (text: string) => Promise<ImapResult<readonly number[]>>;
|
|
263
|
+
/** RFC 6851. Answers with the RFC 4315 `COPYUID`, or null when the server gave none. Refuses without MOVE. */
|
|
264
|
+
readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<CopyUid | null>>;
|
|
268
265
|
readonly create: (mailbox: string) => Promise<ImapResult<void>>;
|
|
269
266
|
readonly noop: () => Promise<ImapResult<void>>;
|
|
270
|
-
/**
|
|
271
|
-
* RFC 2177 IDLE. Occupies the command queue until `done()`: every other command waits
|
|
272
|
-
* behind it, so the caller MUST call `done()` before awaiting anything else. Untagged
|
|
273
|
-
* responses that arrive while idling (EXISTS, EXPUNGE, FETCH) go to `onUntagged`.
|
|
274
|
-
* Resolves once the server's tagged completion of the IDLE arrives after DONE.
|
|
275
|
-
*/
|
|
267
|
+
/** RFC 2177. Occupies the command queue until `done()`; untagged responses meanwhile go to `onUntagged`. */
|
|
276
268
|
readonly idle: () => ImapIdle;
|
|
277
269
|
readonly logout: () => Promise<ImapResult<void>>;
|
|
278
270
|
};
|
|
279
|
-
|
|
271
|
+
/** RFC 4315 `[APPENDUID <uidvalidity> <uid>]`. */
|
|
272
|
+
type AppendUid = {
|
|
273
|
+
readonly uidValidity: number;
|
|
274
|
+
readonly uid: number;
|
|
275
|
+
};
|
|
276
|
+
/** `first` to `last` inclusive, ascending: RFC 4315 §3 reads a range `12:10` as 10, 11, 12. */
|
|
277
|
+
type UidRange = readonly [first: number, last: number];
|
|
278
|
+
/**
|
|
279
|
+
* RFC 4315 `COPYUID`: the n-th uid of `source` is now the n-th uid of `target`, in a destination
|
|
280
|
+
* mailbox whose UIDVALIDITY is `uidValidity`. Kept as ranges: a server's `1:4294967295` costs two
|
|
281
|
+
* numbers, never four billion. Read one uid's destination with `copiedUid`.
|
|
282
|
+
*/
|
|
283
|
+
type CopyUid = {
|
|
284
|
+
readonly uidValidity: number;
|
|
285
|
+
readonly source: readonly UidRange[];
|
|
286
|
+
readonly target: readonly UidRange[];
|
|
287
|
+
};
|
|
288
|
+
/** Where `COPYUID` says the message at `uid` went, or null when it does not mention it. */
|
|
289
|
+
export declare const copiedUid: ({ source, target }: CopyUid, uid: number) => number | null;
|
|
290
|
+
export declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOptions) => ImapClient;
|
|
280
291
|
//#endregion
|
|
281
292
|
//#region src/rfc2047.d.ts
|
|
293
|
+
export declare const decodeRfc2047: (header: string) => string;
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/uid-set.d.ts
|
|
296
|
+
/**
|
|
297
|
+
* RFC 7162 §4 asks a client to keep a command line to about 8192 octets. A set stops well short, so
|
|
298
|
+
* the tag, the command and a mailbox name fit beside it.
|
|
299
|
+
*/
|
|
300
|
+
export declare const MAX_UID_SET_LENGTH = 4096;
|
|
282
301
|
/**
|
|
283
|
-
* RFC
|
|
284
|
-
*
|
|
285
|
-
* Decodes encoded-words in header fields (Q and B encodings).
|
|
286
|
-
* Crucial invariants:
|
|
287
|
-
* 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
|
|
288
|
-
* raw decoded bytes before charset decoding.
|
|
289
|
-
* 2. The output is NEVER re-scanned.
|
|
290
|
-
* 3. Unknown charsets leave the raw word intact.
|
|
302
|
+
* Uids as RFC 9051 sequence sets, runs written as ranges (`3:5,9`), split so that no set is longer
|
|
303
|
+
* than `maxLength`: one command per set, however many uids are written to at once.
|
|
291
304
|
*/
|
|
292
|
-
declare const
|
|
305
|
+
export declare const uidSets: (uids: Iterable<number>, maxLength?: number) => readonly string[];
|
|
293
306
|
//#endregion
|
|
294
|
-
export {
|
|
307
|
+
export type { ByteDuplex, CopyUid, ImapAddress, ImapClient, ImapClientOptions, ImapContinuation, ImapEnvelope, ImapFailure, ImapFetchItem, ImapIdle, ImapMailbox, ImapMessageSummary, ImapResponse, ImapResponseCode, ImapResult, ImapSelected, ImapTagged, ImapToken, ImapUntagged };
|
package/dist/index.mjs
CHANGED
|
@@ -49,14 +49,7 @@ const bytesToBase64 = (bytes) => {
|
|
|
49
49
|
};
|
|
50
50
|
//#endregion
|
|
51
51
|
//#region src/utf7.ts
|
|
52
|
-
/**
|
|
53
|
-
* RFC 3501 §5.1.3 Modified UTF-7 encoding and decoding for IMAP mailbox names.
|
|
54
|
-
*
|
|
55
|
-
* Printable ASCII (0x20..0x7E) except '&' are direct.
|
|
56
|
-
* '&' is encoded as '&-'.
|
|
57
|
-
* Non-ASCII characters are encoded as UTF-16BE in modified Base64 (',' instead of '/', no '=' padding),
|
|
58
|
-
* enclosed between '&' and '-'.
|
|
59
|
-
*/
|
|
52
|
+
/** RFC 3501 §5.1.3 modified UTF-7: UTF-16BE in base64 with `,` for `/` and no padding, between `&` and `-`. */
|
|
60
53
|
const b64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,";
|
|
61
54
|
const encodeModifiedBase64 = (codeUnits) => {
|
|
62
55
|
const bytes = [];
|
|
@@ -156,10 +149,6 @@ const decodeModifiedUtf7 = (str) => {
|
|
|
156
149
|
};
|
|
157
150
|
//#endregion
|
|
158
151
|
//#region src/commands.ts
|
|
159
|
-
/**
|
|
160
|
-
* Pure builders for IMAP commands. Each builder returns exact bytes to send.
|
|
161
|
-
* No I/O is performed here.
|
|
162
|
-
*/
|
|
163
152
|
const quoteString = (str) => {
|
|
164
153
|
return `"${str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
165
154
|
};
|
|
@@ -182,11 +171,13 @@ const buildSaslPlainBytes = (user, pass) => {
|
|
|
182
171
|
};
|
|
183
172
|
const buildCapabilityCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} CAPABILITY\r\n`) }] });
|
|
184
173
|
const buildAuthenticatePlainSaslIrCommand = (tag, user, pass) => {
|
|
185
|
-
|
|
174
|
+
const base64Auth = bytesToBase64(buildSaslPlainBytes(user, pass));
|
|
175
|
+
return { lines: [{ text: stringToBytes(`${tag} AUTHENTICATE PLAIN ${base64Auth}\r\n`) }] };
|
|
186
176
|
};
|
|
187
177
|
const buildAuthenticatePlainInitialCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} AUTHENTICATE PLAIN\r\n`) }] });
|
|
188
178
|
const buildAuthenticatePlainResponse = (user, pass) => {
|
|
189
|
-
|
|
179
|
+
const base64Auth = bytesToBase64(buildSaslPlainBytes(user, pass));
|
|
180
|
+
return { lines: [{ text: stringToBytes(`${base64Auth}\r\n`) }] };
|
|
190
181
|
};
|
|
191
182
|
const buildLoginCommand = (tag, user, pass) => {
|
|
192
183
|
const userSafe = isSafeQuotedString(user);
|
|
@@ -218,32 +209,67 @@ const buildListCommand = (tag, reference, pattern) => {
|
|
|
218
209
|
return { lines: [{ text: stringToBytes(`${tag} LIST ${quoteString(refUtf7)} ${quoteString(patUtf7)}\r\n`) }] };
|
|
219
210
|
};
|
|
220
211
|
const buildSelectCommand = (tag, mailbox, readOnly = false) => {
|
|
221
|
-
|
|
212
|
+
const verb = readOnly ? "EXAMINE" : "SELECT";
|
|
213
|
+
const mboxUtf7 = encodeModifiedUtf7(mailbox);
|
|
214
|
+
return { lines: [{ text: stringToBytes(`${tag} ${verb} ${quoteString(mboxUtf7)}\r\n`) }] };
|
|
222
215
|
};
|
|
223
|
-
/**
|
|
224
|
-
* Everything a list row and client-side threading need, in one round trip. `References` is the
|
|
225
|
-
* one header ENVELOPE does not carry; `X-GM-THRID` is Gmail's own threading answer and is only
|
|
226
|
-
* asked for when the server advertised `X-GM-EXT-1`, since an unknown item is a BAD.
|
|
227
|
-
*
|
|
228
|
-
* `bySeq` drops the `UID` prefix, so `set` is read as message sequence numbers instead. `UID`
|
|
229
|
-
* stays in the items either way: a summary is worthless without the id its folder is keyed by.
|
|
230
|
-
*/
|
|
216
|
+
/** `X-GM-THRID` is asked for only when the server advertised `X-GM-EXT-1`: an unknown item is a BAD. */
|
|
231
217
|
const buildFetchSummariesCommand = (tag, set, { gmail, bySeq = false }) => ({ lines: [{ text: stringToBytes(`${tag} ${bySeq ? "FETCH" : "UID FETCH"} ${set} (FLAGS ENVELOPE INTERNALDATE RFC822.SIZE UID BODY.PEEK[HEADER.FIELDS (REFERENCES)]${gmail ? " X-GM-THRID" : ""})\r\n`) }] });
|
|
232
218
|
const buildFetchFlagsCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (FLAGS UID)\r\n`) }] });
|
|
233
219
|
const buildFetchRawCommand = (tag, uid) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uid} (BODY.PEEK[])\r\n`) }] });
|
|
220
|
+
const STORE_OPS = {
|
|
221
|
+
add: "+FLAGS",
|
|
222
|
+
remove: "-FLAGS",
|
|
223
|
+
set: "FLAGS"
|
|
224
|
+
};
|
|
234
225
|
const buildStoreFlagsCommand = (tag, uidSet, mode, flags) => {
|
|
235
|
-
|
|
226
|
+
const op = STORE_OPS[mode];
|
|
227
|
+
return { lines: [{ text: stringToBytes(`${tag} UID STORE ${uidSet} ${op} (${flags.join(" ")})\r\n`) }] };
|
|
228
|
+
};
|
|
229
|
+
const MONTHS = [
|
|
230
|
+
"Jan",
|
|
231
|
+
"Feb",
|
|
232
|
+
"Mar",
|
|
233
|
+
"Apr",
|
|
234
|
+
"May",
|
|
235
|
+
"Jun",
|
|
236
|
+
"Jul",
|
|
237
|
+
"Aug",
|
|
238
|
+
"Sep",
|
|
239
|
+
"Oct",
|
|
240
|
+
"Nov",
|
|
241
|
+
"Dec"
|
|
242
|
+
];
|
|
243
|
+
const two = (value) => String(value).padStart(2, "0");
|
|
244
|
+
/** RFC 9051 date-time. The day is space-padded, which the grammar requires and several servers enforce. */
|
|
245
|
+
const formatImapDateTime = (date) => {
|
|
246
|
+
const offset = -date.getTimezoneOffset();
|
|
247
|
+
const zone = `${offset < 0 ? "-" : "+"}${two(Math.floor(Math.abs(offset) / 60))}${two(Math.abs(offset) % 60)}`;
|
|
248
|
+
return `"${String(date.getDate()).padStart(2, " ")}-${MONTHS[date.getMonth()]}-${date.getFullYear()} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${zone}"`;
|
|
236
249
|
};
|
|
237
|
-
|
|
238
|
-
const
|
|
250
|
+
const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
|
|
251
|
+
const mboxUtf7 = encodeModifiedUtf7(mailbox);
|
|
252
|
+
const stamp = internalDate === void 0 ? "" : `${formatImapDateTime(internalDate)} `;
|
|
239
253
|
return { lines: [{
|
|
240
|
-
text: stringToBytes(`${tag} APPEND ${quoteString(
|
|
254
|
+
text: stringToBytes(`${tag} APPEND ${quoteString(mboxUtf7)} (${flags.join(" ")}) ${stamp}`),
|
|
241
255
|
literal: message
|
|
242
256
|
}, { text: stringToBytes("\r\n") }] };
|
|
243
257
|
};
|
|
244
|
-
|
|
258
|
+
const buildExpungeCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} EXPUNGE\r\n`) }] });
|
|
259
|
+
/** RFC 4315. */
|
|
260
|
+
const buildUidExpungeCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID EXPUNGE ${uidSet}\r\n`) }] });
|
|
261
|
+
/** A server need only index the headers IMAP names; on a private `X-` header it may answer the empty list. */
|
|
262
|
+
const buildUidSearchHeaderCommand = (tag, header, value) => ({ lines: [{ text: stringToBytes(`${tag} UID SEARCH HEADER ${quoteString(header)} ${quoteString(value)}\r\n`) }] });
|
|
263
|
+
/**
|
|
264
|
+
* One `UID SEARCH <key> <text>`. A printable-ASCII query is a quoted string in the default charset;
|
|
265
|
+
* anything else is a UTF-8 literal, which every server must accept.
|
|
266
|
+
*/
|
|
267
|
+
const buildUidSearchTextCommand = (tag, key, text) => isSafeQuotedString(text) ? { lines: [{ text: stringToBytes(`${tag} UID SEARCH ${key} ${quoteString(text)}\r\n`) }] } : { lines: [{
|
|
268
|
+
text: stringToBytes(`${tag} UID SEARCH CHARSET UTF-8 ${key} `),
|
|
269
|
+
literal: stringToBytes(text)
|
|
270
|
+
}, { text: stringToBytes("\r\n") }] };
|
|
271
|
+
/** RFC 6851. */
|
|
245
272
|
const buildMoveCommand = (tag, uidSet, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} UID MOVE ${uidSet} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
|
|
246
|
-
/** CREATE a mailbox (e.g. Archive the first time the client needs one). */
|
|
247
273
|
const buildCreateCommand = (tag, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} CREATE ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
|
|
248
274
|
const buildNoopCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} NOOP\r\n`) }] });
|
|
249
275
|
const buildIdleCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} IDLE\r\n`) }] });
|
|
@@ -252,16 +278,6 @@ const buildIdleDoneLine = () => stringToBytes("DONE\r\n");
|
|
|
252
278
|
const buildLogoutCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} LOGOUT\r\n`) }] });
|
|
253
279
|
//#endregion
|
|
254
280
|
//#region src/rfc2047.ts
|
|
255
|
-
/**
|
|
256
|
-
* RFC 2047 MIME Part Three: Message Header Extensions for Non-ASCII Text.
|
|
257
|
-
*
|
|
258
|
-
* Decodes encoded-words in header fields (Q and B encodings).
|
|
259
|
-
* Crucial invariants:
|
|
260
|
-
* 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
|
|
261
|
-
* raw decoded bytes before charset decoding.
|
|
262
|
-
* 2. The output is NEVER re-scanned.
|
|
263
|
-
* 3. Unknown charsets leave the raw word intact.
|
|
264
|
-
*/
|
|
265
281
|
const ENCODED_WORD_REGEX = /=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g;
|
|
266
282
|
const decodeQToBytes = (text) => {
|
|
267
283
|
const bytes = [];
|
|
@@ -373,7 +389,8 @@ const decodeRfc2047 = (header) => {
|
|
|
373
389
|
}
|
|
374
390
|
if (hasDecodeError) for (const w of sameCharsetWords) result += w.raw;
|
|
375
391
|
else {
|
|
376
|
-
const
|
|
392
|
+
const mergedBytes = concatByteArrays(byteChunks);
|
|
393
|
+
const decodedText = decodeBytesWithCharset(mergedBytes, firstInCharset.charset);
|
|
377
394
|
if (decodedText !== null) result += decodedText;
|
|
378
395
|
else for (const w of sameCharsetWords) result += w.raw;
|
|
379
396
|
}
|
|
@@ -389,9 +406,6 @@ const decodeRfc2047 = (header) => {
|
|
|
389
406
|
};
|
|
390
407
|
//#endregion
|
|
391
408
|
//#region src/envelope.ts
|
|
392
|
-
/**
|
|
393
|
-
* Parsers for IMAP FETCH items: ENVELOPE, FLAGS, INTERNALDATE, RFC822.SIZE, BODYSTRUCTURE.
|
|
394
|
-
*/
|
|
395
409
|
const tokenToString$1 = (token) => {
|
|
396
410
|
if (token === void 0 || token.kind === "nil") return null;
|
|
397
411
|
if (token.kind === "quoted" || token.kind === "atom") return token.value;
|
|
@@ -399,9 +413,7 @@ const tokenToString$1 = (token) => {
|
|
|
399
413
|
if (token.kind === "literal") return asciiToString(token.value);
|
|
400
414
|
return null;
|
|
401
415
|
};
|
|
402
|
-
/**
|
|
403
|
-
* Parses a single address tuple `(name adl mailbox host)` and advances index past `)`.
|
|
404
|
-
*/
|
|
416
|
+
/** RFC 9051 address: `(name adl mailbox host)`. */
|
|
405
417
|
const parseAddressTuple = (tokens, start) => {
|
|
406
418
|
let idx = start;
|
|
407
419
|
if (tokens[idx]?.kind !== "lparen") return null;
|
|
@@ -437,10 +449,6 @@ const parseAddressTuple = (tokens, start) => {
|
|
|
437
449
|
nextIndex: idx
|
|
438
450
|
};
|
|
439
451
|
};
|
|
440
|
-
/**
|
|
441
|
-
* Parses an address list `( (addr1) (addr2) ... )` or `NIL`.
|
|
442
|
-
* Flattening groups to their member addresses.
|
|
443
|
-
*/
|
|
444
452
|
const parseAddressList = (tokens, start) => {
|
|
445
453
|
const first = tokens[start];
|
|
446
454
|
if (first === void 0 || first.kind === "nil") return {
|
|
@@ -473,10 +481,7 @@ const parseAddressList = (tokens, start) => {
|
|
|
473
481
|
nextIndex: idx
|
|
474
482
|
};
|
|
475
483
|
};
|
|
476
|
-
/**
|
|
477
|
-
* Parses an ENVELOPE structure:
|
|
478
|
-
* `(date subject from sender reply-to to cc bcc in-reply-to message-id)`
|
|
479
|
-
*/
|
|
484
|
+
/** RFC 9051 envelope: `(date subject from sender reply-to to cc bcc in-reply-to message-id)`. */
|
|
480
485
|
const parseEnvelope = (tokens, start) => {
|
|
481
486
|
if (tokens[start]?.kind !== "lparen") return null;
|
|
482
487
|
let idx = start + 1;
|
|
@@ -526,9 +531,7 @@ const parseEnvelope = (tokens, start) => {
|
|
|
526
531
|
nextIndex: idx
|
|
527
532
|
};
|
|
528
533
|
};
|
|
529
|
-
/**
|
|
530
|
-
* Parses BODYSTRUCTURE, extracting only the list of MIME parts (e.g. ['TEXT/PLAIN', 'TEXT/HTML']).
|
|
531
|
-
*/
|
|
534
|
+
/** Only the MIME types, e.g. `['TEXT/PLAIN', 'TEXT/HTML']`. */
|
|
532
535
|
const parseBodyStructureParts = (tokens, start) => {
|
|
533
536
|
if (tokens[start]?.kind !== "lparen") return {
|
|
534
537
|
parts: [],
|
|
@@ -569,9 +572,6 @@ const parseBodyStructureParts = (tokens, start) => {
|
|
|
569
572
|
nextIndex: idx
|
|
570
573
|
};
|
|
571
574
|
};
|
|
572
|
-
/**
|
|
573
|
-
* Parses items inside a FETCH response `(...)`.
|
|
574
|
-
*/
|
|
575
575
|
const parseFetchItems = (tokens, start) => {
|
|
576
576
|
if (tokens[start]?.kind !== "lparen") return {
|
|
577
577
|
items: [],
|
|
@@ -611,21 +611,22 @@ const parseFetchItems = (tokens, start) => {
|
|
|
611
611
|
kind: "internalDate",
|
|
612
612
|
date: dateStr
|
|
613
613
|
});
|
|
614
|
-
} else if (itemName === "FLAGS")
|
|
615
|
-
idx
|
|
616
|
-
const flags = [];
|
|
617
|
-
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
|
|
618
|
-
const flagTok = tokens[idx];
|
|
619
|
-
if (flagTok?.kind === "atom") flags.push(flagTok.value);
|
|
614
|
+
} else if (itemName === "FLAGS") {
|
|
615
|
+
if (tokens[idx]?.kind === "lparen") {
|
|
620
616
|
idx++;
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
617
|
+
const flags = [];
|
|
618
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
|
|
619
|
+
const flagTok = tokens[idx];
|
|
620
|
+
if (flagTok?.kind === "atom") flags.push(flagTok.value);
|
|
621
|
+
idx++;
|
|
622
|
+
}
|
|
623
|
+
if (tokens[idx]?.kind === "rparen") idx++;
|
|
624
|
+
items.push({
|
|
625
|
+
kind: "flags",
|
|
626
|
+
flags
|
|
627
|
+
});
|
|
628
|
+
} else idx++;
|
|
629
|
+
} else if (itemName === "ENVELOPE") {
|
|
629
630
|
const envResult = parseEnvelope(tokens, idx);
|
|
630
631
|
if (envResult !== null) {
|
|
631
632
|
items.push({
|
|
@@ -634,45 +635,46 @@ const parseFetchItems = (tokens, start) => {
|
|
|
634
635
|
});
|
|
635
636
|
idx = envResult.nextIndex;
|
|
636
637
|
} else idx++;
|
|
637
|
-
} else if (itemName === "BODYSTRUCTURE" || itemName === "BODY" || itemName === "BODY.PEEK")
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
idx
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (secTok
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
638
|
+
} else if (itemName === "BODYSTRUCTURE" || itemName === "BODY" || itemName === "BODY.PEEK") {
|
|
639
|
+
if (tokens[idx]?.kind === "lparen") {
|
|
640
|
+
const bsResult = parseBodyStructureParts(tokens, idx);
|
|
641
|
+
items.push({
|
|
642
|
+
kind: "bodyStructure",
|
|
643
|
+
parts: bsResult.parts
|
|
644
|
+
});
|
|
645
|
+
idx = bsResult.nextIndex;
|
|
646
|
+
} else if (tokens[idx]?.kind === "lbracket") {
|
|
647
|
+
idx++;
|
|
648
|
+
const sectionParts = [];
|
|
649
|
+
while (idx < tokens.length && tokens[idx]?.kind !== "rbracket") {
|
|
650
|
+
const secTok = tokens[idx];
|
|
651
|
+
if (secTok !== void 0) {
|
|
652
|
+
if (secTok.kind === "atom" || secTok.kind === "quoted") sectionParts.push(secTok.value);
|
|
653
|
+
else if (secTok.kind === "number") sectionParts.push(String(secTok.value));
|
|
654
|
+
else if (secTok.kind === "lparen") sectionParts.push("(");
|
|
655
|
+
else if (secTok.kind === "rparen") sectionParts.push(")");
|
|
656
|
+
else if (secTok.kind === "nil") sectionParts.push("NIL");
|
|
657
|
+
else if (secTok.kind === "literal") sectionParts.push(asciiToString(secTok.value));
|
|
658
|
+
}
|
|
659
|
+
idx++;
|
|
656
660
|
}
|
|
661
|
+
if (tokens[idx]?.kind === "rbracket") idx++;
|
|
662
|
+
const section = sectionParts.join(" ");
|
|
663
|
+
const originTok = tokens[idx];
|
|
664
|
+
if (originTok?.kind === "atom" && /^<\d+(\.\d+)?>$/.test(originTok.value)) idx++;
|
|
665
|
+
const bodyTok = tokens[idx];
|
|
657
666
|
idx++;
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
else
|
|
668
|
-
|
|
669
|
-
items.push({
|
|
670
|
-
kind: "body",
|
|
671
|
-
section,
|
|
672
|
-
bytes
|
|
673
|
-
});
|
|
674
|
-
} else idx++;
|
|
675
|
-
else if (itemName === "X-GM-THRID") {
|
|
667
|
+
let bytes = null;
|
|
668
|
+
if (bodyTok?.kind === "literal") bytes = bodyTok.value;
|
|
669
|
+
else if (bodyTok?.kind === "quoted" || bodyTok?.kind === "atom") bytes = stringToBytes(bodyTok.value);
|
|
670
|
+
else if (bodyTok?.kind === "nil") bytes = null;
|
|
671
|
+
items.push({
|
|
672
|
+
kind: "body",
|
|
673
|
+
section,
|
|
674
|
+
bytes
|
|
675
|
+
});
|
|
676
|
+
} else idx++;
|
|
677
|
+
} else if (itemName === "X-GM-THRID") {
|
|
676
678
|
const id = tokenToString$1(tokens[idx]);
|
|
677
679
|
idx++;
|
|
678
680
|
if (id !== null && /^\d+$/.test(id)) items.push({
|
|
@@ -695,9 +697,6 @@ const parseFetchItems = (tokens, start) => {
|
|
|
695
697
|
};
|
|
696
698
|
//#endregion
|
|
697
699
|
//#region src/response.ts
|
|
698
|
-
/**
|
|
699
|
-
* IMAP response parser: converts token stream into typed tagged, untagged, and continuation responses.
|
|
700
|
-
*/
|
|
701
700
|
const tokenToString = (token) => {
|
|
702
701
|
if (token === void 0 || token.kind === "nil") return null;
|
|
703
702
|
if (token.kind === "quoted" || token.kind === "atom") return token.value;
|
|
@@ -945,6 +944,7 @@ const parseResponse = (tokens) => {
|
|
|
945
944
|
}
|
|
946
945
|
const delimiter = tokenToString(tokens[idx]);
|
|
947
946
|
idx++;
|
|
947
|
+
const rawName = tokenToString(tokens[idx]) ?? "";
|
|
948
948
|
return {
|
|
949
949
|
ok: true,
|
|
950
950
|
value: {
|
|
@@ -952,7 +952,7 @@ const parseResponse = (tokens) => {
|
|
|
952
952
|
untagged: {
|
|
953
953
|
kind: "list",
|
|
954
954
|
mailbox: {
|
|
955
|
-
name: decodeModifiedUtf7(
|
|
955
|
+
name: decodeModifiedUtf7(rawName),
|
|
956
956
|
delimiter,
|
|
957
957
|
attributes
|
|
958
958
|
}
|
|
@@ -1083,22 +1083,8 @@ const parseResponse = (tokens) => {
|
|
|
1083
1083
|
};
|
|
1084
1084
|
//#endregion
|
|
1085
1085
|
//#region src/tokenizer.ts
|
|
1086
|
-
|
|
1087
|
-
* Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
|
|
1088
|
-
*
|
|
1089
|
-
* Implements total parsing over attacker-controlled bytes:
|
|
1090
|
-
* - Line ends at CRLF; bare LF is a protocol failure.
|
|
1091
|
-
* - Logical line frames `{n}` literals (and `{n+}` LITERAL+).
|
|
1092
|
-
* - Enforces maxLiteralBytes (default 32 MiB).
|
|
1093
|
-
* - A number above 2^32 - 1 is an atom of digits, not a failure: RFC 9051's `number` is
|
|
1094
|
-
* 32-bit, but Gmail's X-GM-THRID is 64-bit and arrives as a bare number.
|
|
1095
|
-
* - Never throws on malformed input; returns typed ImapResult.
|
|
1096
|
-
*/
|
|
1097
|
-
const DEFAULT_MAX_LITERAL_BYTES = 32 * 1024 * 1024;
|
|
1086
|
+
const DEFAULT_MAX_LITERAL_BYTES = 33554432;
|
|
1098
1087
|
const MAX_UINT32 = 4294967295;
|
|
1099
|
-
/**
|
|
1100
|
-
* Checks for bare LF in a slice of bytes.
|
|
1101
|
-
*/
|
|
1102
1088
|
const checkBareLf = (bytes, start, end) => {
|
|
1103
1089
|
for (let i = start; i < end; i++) if (bytes[i] === 10 && (i === 0 || bytes[i - 1] !== 13)) return {
|
|
1104
1090
|
kind: "protocol",
|
|
@@ -1106,10 +1092,6 @@ const checkBareLf = (bytes, start, end) => {
|
|
|
1106
1092
|
};
|
|
1107
1093
|
return null;
|
|
1108
1094
|
};
|
|
1109
|
-
/**
|
|
1110
|
-
* Parses literal length from `{<digits>}` or `{<digits>+}` before CRLF.
|
|
1111
|
-
* Returns valid length, invalid failure, or none if not a literal header.
|
|
1112
|
-
*/
|
|
1113
1095
|
const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
|
|
1114
1096
|
if (crlfIndex === 0) return { kind: "none" };
|
|
1115
1097
|
let i = crlfIndex - 1;
|
|
@@ -1147,9 +1129,6 @@ const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
|
|
|
1147
1129
|
braceIndex
|
|
1148
1130
|
};
|
|
1149
1131
|
};
|
|
1150
|
-
/**
|
|
1151
|
-
* Scans `buffer` for a complete logical line (including any literals).
|
|
1152
|
-
*/
|
|
1153
1132
|
const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, resume) => {
|
|
1154
1133
|
const segments = resume?.segments ? [...resume.segments] : [];
|
|
1155
1134
|
let scanOffset = resume?.resumeAt ?? 0;
|
|
@@ -1257,9 +1236,6 @@ const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, re
|
|
|
1257
1236
|
textStart
|
|
1258
1237
|
};
|
|
1259
1238
|
};
|
|
1260
|
-
/**
|
|
1261
|
-
* Tokenizes a text segment into IMAP tokens.
|
|
1262
|
-
*/
|
|
1263
1239
|
const tokenizeTextSegment = (bytes, tokens) => {
|
|
1264
1240
|
let i = 0;
|
|
1265
1241
|
const len = bytes.length;
|
|
@@ -1371,9 +1347,6 @@ const tokenizeTextSegment = (bytes, tokens) => {
|
|
|
1371
1347
|
value: void 0
|
|
1372
1348
|
};
|
|
1373
1349
|
};
|
|
1374
|
-
/**
|
|
1375
|
-
* Tokenizes a complete LogicalLine into ImapTokens.
|
|
1376
|
-
*/
|
|
1377
1350
|
const tokenizeLogicalLine = (line) => {
|
|
1378
1351
|
const tokens = [];
|
|
1379
1352
|
for (const seg of line.segments) if (seg.kind === "literal") tokens.push({
|
|
@@ -1391,30 +1364,98 @@ const tokenizeLogicalLine = (line) => {
|
|
|
1391
1364
|
};
|
|
1392
1365
|
//#endregion
|
|
1393
1366
|
//#region src/client.ts
|
|
1367
|
+
const appendUidOf = (tagged) => {
|
|
1368
|
+
const code = tagged.code;
|
|
1369
|
+
if (code?.kind !== "other" || code.code !== "APPENDUID") return null;
|
|
1370
|
+
const [uidValidity, uid] = code.args.map(Number);
|
|
1371
|
+
if (uidValidity === void 0 || uid === void 0) return null;
|
|
1372
|
+
return Number.isInteger(uidValidity) && Number.isInteger(uid) ? {
|
|
1373
|
+
uidValidity,
|
|
1374
|
+
uid
|
|
1375
|
+
} : null;
|
|
1376
|
+
};
|
|
1377
|
+
/** RFC 3501 nz-number: what a uid can be. */
|
|
1378
|
+
const MAX_UID = 4294967295;
|
|
1379
|
+
const uidOf = (text) => {
|
|
1380
|
+
if (!/^[1-9][0-9]*$/.test(text)) return null;
|
|
1381
|
+
const uid = Number(text);
|
|
1382
|
+
return uid <= MAX_UID ? uid : null;
|
|
1383
|
+
};
|
|
1384
|
+
/** A uid set without `*`, which COPYUID never carries; null when malformed. */
|
|
1385
|
+
const uidRangesOf = (text) => {
|
|
1386
|
+
const ranges = [];
|
|
1387
|
+
for (const part of text.split(",")) {
|
|
1388
|
+
const bounds = part.split(":").map(uidOf);
|
|
1389
|
+
const uids = bounds.filter((uid) => uid !== null);
|
|
1390
|
+
const [first, last] = uids;
|
|
1391
|
+
if (first === void 0 || uids.length < bounds.length || uids.length > 2) return null;
|
|
1392
|
+
const end = last ?? first;
|
|
1393
|
+
ranges.push([Math.min(first, end), Math.max(first, end)]);
|
|
1394
|
+
}
|
|
1395
|
+
return ranges;
|
|
1396
|
+
};
|
|
1397
|
+
const sizeOfRange = ([first, last]) => last - first + 1;
|
|
1398
|
+
const sizeOf = (ranges) => ranges.reduce((size, range) => size + sizeOfRange(range), 0);
|
|
1399
|
+
const copyUidOfCode = (code) => {
|
|
1400
|
+
if (code?.kind !== "other" || code.code !== "COPYUID") return null;
|
|
1401
|
+
const [validity, sourceSet, targetSet] = code.args;
|
|
1402
|
+
if (validity === void 0 || sourceSet === void 0 || targetSet === void 0) return null;
|
|
1403
|
+
const uidValidity = uidOf(validity);
|
|
1404
|
+
const source = uidRangesOf(sourceSet);
|
|
1405
|
+
const target = uidRangesOf(targetSet);
|
|
1406
|
+
if (uidValidity === null || source === null || target === null) return null;
|
|
1407
|
+
return sizeOf(source) === sizeOf(target) ? {
|
|
1408
|
+
uidValidity,
|
|
1409
|
+
source,
|
|
1410
|
+
target
|
|
1411
|
+
} : null;
|
|
1412
|
+
};
|
|
1394
1413
|
/**
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
* - One IMAP connection object per device (no connection pool).
|
|
1399
|
-
* - Transport is ByteDuplex from @yozz.app/tls.
|
|
1400
|
-
* - Parsing is total: parser never throws, returns typed ImapFailure and closes.
|
|
1401
|
-
* - Commands are serialised: one in-flight command at a time, queued in order (no pipelining in this slice).
|
|
1402
|
-
* - Greeting is captured immediately upon creation.
|
|
1403
|
-
*/
|
|
1404
|
-
/**
|
|
1405
|
-
* The msg-ids out of a `HEADER.FIELDS (REFERENCES)` section: the header unfolded, then every
|
|
1406
|
-
* `<...>` in order. A truncated or absent header is simply fewer ids.
|
|
1414
|
+
* RFC 6851 §4.3 has a MOVE's COPYUID in an untagged OK ahead of its EXPUNGEs (Gmail, Dovecot);
|
|
1415
|
+
* RFC 4315 puts a COPY's on the tagged OK, and some servers do that for MOVE too. Either, or
|
|
1416
|
+
* several of them, read as one answer.
|
|
1407
1417
|
*/
|
|
1418
|
+
const copyUidOf = (untagged, tagged) => {
|
|
1419
|
+
const answers = [...untagged.map((response) => response.kind === "status" ? response.code : null), tagged.code].flatMap((code) => copyUidOfCode(code) ?? []);
|
|
1420
|
+
const [first] = answers;
|
|
1421
|
+
if (first === void 0) return null;
|
|
1422
|
+
if (answers.some((answer) => answer.uidValidity !== first.uidValidity)) return null;
|
|
1423
|
+
return {
|
|
1424
|
+
uidValidity: first.uidValidity,
|
|
1425
|
+
source: answers.flatMap((answer) => answer.source),
|
|
1426
|
+
target: answers.flatMap((answer) => answer.target)
|
|
1427
|
+
};
|
|
1428
|
+
};
|
|
1429
|
+
/** How many uids come before `uid`, counting through `ranges` in order; null when none holds it. */
|
|
1430
|
+
const positionOf = (ranges, uid) => {
|
|
1431
|
+
let before = 0;
|
|
1432
|
+
for (const range of ranges) {
|
|
1433
|
+
const [first, last] = range;
|
|
1434
|
+
if (uid >= first && uid <= last) return before + uid - first;
|
|
1435
|
+
before += sizeOfRange(range);
|
|
1436
|
+
}
|
|
1437
|
+
return null;
|
|
1438
|
+
};
|
|
1439
|
+
/** The uid at `position`, counting through `ranges` in order; null past their end. */
|
|
1440
|
+
const uidAt = (ranges, position) => {
|
|
1441
|
+
let before = 0;
|
|
1442
|
+
for (const range of ranges) {
|
|
1443
|
+
if (position < before + sizeOfRange(range)) return range[0] + position - before;
|
|
1444
|
+
before += sizeOfRange(range);
|
|
1445
|
+
}
|
|
1446
|
+
return null;
|
|
1447
|
+
};
|
|
1448
|
+
/** Where `COPYUID` says the message at `uid` went, or null when it does not mention it. */
|
|
1449
|
+
const copiedUid = ({ source, target }, uid) => {
|
|
1450
|
+
const position = positionOf(source, uid);
|
|
1451
|
+
return position === null ? null : uidAt(target, position);
|
|
1452
|
+
};
|
|
1408
1453
|
const parseReferencesHeader = (bytes) => {
|
|
1409
1454
|
if (bytes === null) return [];
|
|
1410
1455
|
const line = asciiToString(bytes).replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/).find((l) => /^references:/i.test(l));
|
|
1411
1456
|
if (line === void 0) return [];
|
|
1412
1457
|
return line.slice(line.indexOf(":") + 1).match(/<[^<>\s]+>/g) ?? [];
|
|
1413
1458
|
};
|
|
1414
|
-
/**
|
|
1415
|
-
* The summaries in a FETCH response's untagged lines, whether the command asked by uid or by
|
|
1416
|
-
* sequence number: `* <seq> FETCH (UID n …)` carries both either way.
|
|
1417
|
-
*/
|
|
1418
1459
|
const summariesFrom = (untagged) => untagged.flatMap((item) => {
|
|
1419
1460
|
if (item.kind !== "fetch") return [];
|
|
1420
1461
|
let uid = 0;
|
|
@@ -1453,9 +1494,6 @@ const createImapClient = (transport, options) => {
|
|
|
1453
1494
|
let knownCapabilities = [];
|
|
1454
1495
|
let tagCounter = 1;
|
|
1455
1496
|
const nextTag = () => `A${String(tagCounter++).padStart(4, "0")}`;
|
|
1456
|
-
/**
|
|
1457
|
-
* Reads and parses the next complete IMAP response from the transport.
|
|
1458
|
-
*/
|
|
1459
1497
|
const readNextResponse = async () => {
|
|
1460
1498
|
if (failureReason !== null) return {
|
|
1461
1499
|
ok: false,
|
|
@@ -1522,9 +1560,6 @@ const createImapClient = (transport, options) => {
|
|
|
1522
1560
|
totalBufferedBytes += chunk.length;
|
|
1523
1561
|
}
|
|
1524
1562
|
};
|
|
1525
|
-
/**
|
|
1526
|
-
* Greeting is captured immediately as soon as client is created.
|
|
1527
|
-
*/
|
|
1528
1563
|
const greetingPromise = (async () => {
|
|
1529
1564
|
const res = await readNextResponse();
|
|
1530
1565
|
if (!res.ok) return res;
|
|
@@ -1572,9 +1607,6 @@ const createImapClient = (transport, options) => {
|
|
|
1572
1607
|
reason: { kind: "closed" }
|
|
1573
1608
|
}));
|
|
1574
1609
|
};
|
|
1575
|
-
/**
|
|
1576
|
-
* Sends a command and collects untagged responses until matching tagged response.
|
|
1577
|
-
*/
|
|
1578
1610
|
const executeCommand = async (build, options) => {
|
|
1579
1611
|
if (isClosed) return {
|
|
1580
1612
|
ok: false,
|
|
@@ -1742,21 +1774,22 @@ const createImapClient = (transport, options) => {
|
|
|
1742
1774
|
text: resp.untagged.text
|
|
1743
1775
|
};
|
|
1744
1776
|
}
|
|
1745
|
-
} else if (resp.kind === "continuation")
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1777
|
+
} else if (resp.kind === "continuation") {
|
|
1778
|
+
if (options?.onContinuation !== void 0) {
|
|
1779
|
+
const contRes = await options.onContinuation();
|
|
1780
|
+
if (!contRes.ok) return contRes;
|
|
1781
|
+
} else {
|
|
1782
|
+
isClosed = true;
|
|
1783
|
+
failureReason = {
|
|
1784
|
+
kind: "protocol",
|
|
1785
|
+
detail: "Unexpected continuation response from server"
|
|
1786
|
+
};
|
|
1787
|
+
return {
|
|
1788
|
+
ok: false,
|
|
1789
|
+
reason: failureReason
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
} else if (resp.kind === "tagged") {
|
|
1760
1793
|
if (resp.tag !== tag) {
|
|
1761
1794
|
isClosed = true;
|
|
1762
1795
|
failureReason = {
|
|
@@ -1820,25 +1853,26 @@ const createImapClient = (transport, options) => {
|
|
|
1820
1853
|
const hasSaslIr = knownCapabilities.some((c) => c.toUpperCase() === "SASL-IR");
|
|
1821
1854
|
const hasLoginDisabled = knownCapabilities.some((c) => c.toUpperCase() === "LOGINDISABLED");
|
|
1822
1855
|
let authCmdRes;
|
|
1823
|
-
if (hasAuthPlain)
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1856
|
+
if (hasAuthPlain) {
|
|
1857
|
+
if (hasSaslIr) authCmdRes = await executeCommand((tag) => buildAuthenticatePlainSaslIrCommand(tag, username, password));
|
|
1858
|
+
else authCmdRes = await executeCommand((tag) => buildAuthenticatePlainInitialCommand(tag), { onContinuation: async () => {
|
|
1859
|
+
try {
|
|
1860
|
+
const respCmd = buildAuthenticatePlainResponse(username, password);
|
|
1861
|
+
for (const line of respCmd.lines) await transport.write(line.text);
|
|
1862
|
+
return {
|
|
1863
|
+
ok: true,
|
|
1864
|
+
value: void 0
|
|
1865
|
+
};
|
|
1866
|
+
} catch {
|
|
1867
|
+
isClosed = true;
|
|
1868
|
+
failureReason = { kind: "closed" };
|
|
1869
|
+
return {
|
|
1870
|
+
ok: false,
|
|
1871
|
+
reason: { kind: "closed" }
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
} });
|
|
1875
|
+
} else if (!hasLoginDisabled) authCmdRes = await executeCommand((tag) => buildLoginCommand(tag, username, password));
|
|
1842
1876
|
else return {
|
|
1843
1877
|
ok: false,
|
|
1844
1878
|
reason: {
|
|
@@ -1967,14 +2001,63 @@ const createImapClient = (transport, options) => {
|
|
|
1967
2001
|
value: void 0
|
|
1968
2002
|
};
|
|
1969
2003
|
}),
|
|
1970
|
-
append: (mailbox, message, flags) => enqueueCommand(async () => {
|
|
1971
|
-
const res = await executeCommand((tag) => buildAppendCommand(tag, mailbox, flags, message));
|
|
2004
|
+
append: (mailbox, message, flags, internalDate) => enqueueCommand(async () => {
|
|
2005
|
+
const res = await executeCommand((tag) => buildAppendCommand(tag, mailbox, flags, message, internalDate));
|
|
2006
|
+
if (!res.ok) return res;
|
|
2007
|
+
return {
|
|
2008
|
+
ok: true,
|
|
2009
|
+
value: appendUidOf(res.value.tagged)
|
|
2010
|
+
};
|
|
2011
|
+
}),
|
|
2012
|
+
expunge: () => enqueueCommand(async () => {
|
|
2013
|
+
const res = await executeCommand(buildExpungeCommand);
|
|
2014
|
+
if (!res.ok) return res;
|
|
2015
|
+
return {
|
|
2016
|
+
ok: true,
|
|
2017
|
+
value: void 0
|
|
2018
|
+
};
|
|
2019
|
+
}),
|
|
2020
|
+
uidExpunge: (uidSet) => enqueueCommand(async () => {
|
|
2021
|
+
const greetRes = await greetingPromise;
|
|
2022
|
+
if (!greetRes.ok) return greetRes;
|
|
2023
|
+
if (!knownCapabilities.some((c) => c.toUpperCase() === "UIDPLUS")) return {
|
|
2024
|
+
ok: false,
|
|
2025
|
+
reason: {
|
|
2026
|
+
kind: "no",
|
|
2027
|
+
text: "UID EXPUNGE needs UIDPLUS, which this server lacks"
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
const res = await executeCommand((tag) => buildUidExpungeCommand(tag, uidSet));
|
|
1972
2031
|
if (!res.ok) return res;
|
|
1973
2032
|
return {
|
|
1974
2033
|
ok: true,
|
|
1975
2034
|
value: void 0
|
|
1976
2035
|
};
|
|
1977
2036
|
}),
|
|
2037
|
+
uidSearchHeader: (header, value) => enqueueCommand(async () => {
|
|
2038
|
+
const res = await executeCommand((tag) => buildUidSearchHeaderCommand(tag, header, value));
|
|
2039
|
+
if (!res.ok) return res;
|
|
2040
|
+
return {
|
|
2041
|
+
ok: true,
|
|
2042
|
+
value: res.value.untagged.flatMap((item) => item.kind === "search" ? [...item.uids] : [])
|
|
2043
|
+
};
|
|
2044
|
+
}),
|
|
2045
|
+
uidSearchText: (text) => enqueueCommand(async () => {
|
|
2046
|
+
const found = /* @__PURE__ */ new Set();
|
|
2047
|
+
for (const key of [
|
|
2048
|
+
"SUBJECT",
|
|
2049
|
+
"FROM",
|
|
2050
|
+
"TEXT"
|
|
2051
|
+
]) {
|
|
2052
|
+
const res = await executeCommand((tag) => buildUidSearchTextCommand(tag, key, text));
|
|
2053
|
+
if (!res.ok) return res;
|
|
2054
|
+
for (const item of res.value.untagged) if (item.kind === "search") for (const uid of item.uids) found.add(uid);
|
|
2055
|
+
}
|
|
2056
|
+
return {
|
|
2057
|
+
ok: true,
|
|
2058
|
+
value: [...found].toSorted((a, b) => a - b)
|
|
2059
|
+
};
|
|
2060
|
+
}),
|
|
1978
2061
|
move: (uidSet, mailbox) => enqueueCommand(async () => {
|
|
1979
2062
|
const greetRes = await greetingPromise;
|
|
1980
2063
|
if (!greetRes.ok) return greetRes;
|
|
@@ -1992,7 +2075,7 @@ const createImapClient = (transport, options) => {
|
|
|
1992
2075
|
if (!res.ok) return res;
|
|
1993
2076
|
return {
|
|
1994
2077
|
ok: true,
|
|
1995
|
-
value:
|
|
2078
|
+
value: copyUidOf(res.value.untagged, res.value.tagged)
|
|
1996
2079
|
};
|
|
1997
2080
|
}),
|
|
1998
2081
|
create: (mailbox) => enqueueCommand(async () => {
|
|
@@ -2024,9 +2107,8 @@ const createImapClient = (transport, options) => {
|
|
|
2024
2107
|
}
|
|
2025
2108
|
let doneRequested = false;
|
|
2026
2109
|
let doneSent = false;
|
|
2027
|
-
/**
|
|
2110
|
+
/** Set once '+' arrived; only then may DONE be written. */
|
|
2028
2111
|
let isIdling = false;
|
|
2029
|
-
/** True once the IDLE has settled either way; a `done()` after that has nothing to end. */
|
|
2030
2112
|
let isEnded = false;
|
|
2031
2113
|
const sendDone = async () => {
|
|
2032
2114
|
if (doneSent || !isIdling || isEnded) return null;
|
|
@@ -2228,4 +2310,34 @@ const createImapClient = (transport, options) => {
|
|
|
2228
2310
|
};
|
|
2229
2311
|
};
|
|
2230
2312
|
//#endregion
|
|
2231
|
-
|
|
2313
|
+
//#region src/uid-set.ts
|
|
2314
|
+
/**
|
|
2315
|
+
* RFC 7162 §4 asks a client to keep a command line to about 8192 octets. A set stops well short, so
|
|
2316
|
+
* the tag, the command and a mailbox name fit beside it.
|
|
2317
|
+
*/
|
|
2318
|
+
const MAX_UID_SET_LENGTH = 4096;
|
|
2319
|
+
/**
|
|
2320
|
+
* Uids as RFC 9051 sequence sets, runs written as ranges (`3:5,9`), split so that no set is longer
|
|
2321
|
+
* than `maxLength`: one command per set, however many uids are written to at once.
|
|
2322
|
+
*/
|
|
2323
|
+
const uidSets = (uids, maxLength = MAX_UID_SET_LENGTH) => {
|
|
2324
|
+
const ranges = [];
|
|
2325
|
+
for (const uid of [...new Set(uids)].toSorted((a, b) => a - b)) {
|
|
2326
|
+
const last = ranges.at(-1);
|
|
2327
|
+
if (last !== void 0 && uid === last[1] + 1) last[1] = uid;
|
|
2328
|
+
else ranges.push([uid, uid]);
|
|
2329
|
+
}
|
|
2330
|
+
const sets = [];
|
|
2331
|
+
let current = "";
|
|
2332
|
+
for (const [first, last] of ranges) {
|
|
2333
|
+
const range = first === last ? String(first) : `${first}:${last}`;
|
|
2334
|
+
if (current !== "" && current.length + 1 + range.length > maxLength) {
|
|
2335
|
+
sets.push(current);
|
|
2336
|
+
current = "";
|
|
2337
|
+
}
|
|
2338
|
+
current = current === "" ? range : `${current},${range}`;
|
|
2339
|
+
}
|
|
2340
|
+
return current === "" ? sets : [...sets, current];
|
|
2341
|
+
};
|
|
2342
|
+
//#endregion
|
|
2343
|
+
export { DEFAULT_MAX_LITERAL_BYTES, MAX_UID_SET_LENGTH, copiedUid, createImapClient, decodeRfc2047, parseResponse, uidSets };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yozz.app/imap",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Transport-agnostic IMAP4rev2/rev1 client core: literals, total parsing, RFC 2047, SASL PLAIN/LOGIN, IDLE, UID MOVE.",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Transport-agnostic IMAP4rev2/rev1 client core: literals, total parsing, RFC 2047, SASL PLAIN/LOGIN, IDLE, UID MOVE, APPEND with APPENDUID, UID EXPUNGE.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/fishballapp/yozz",
|
|
7
7
|
"repository": {
|
|
@@ -29,10 +29,11 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/node": "^24.
|
|
33
|
-
"
|
|
34
|
-
"@yozz.app/tls": "0.1.
|
|
35
|
-
"@yozz.app/x509": "0.1.
|
|
32
|
+
"@types/node": "^24.13.4",
|
|
33
|
+
"@yozz.app/smtp": "0.1.4",
|
|
34
|
+
"@yozz.app/tls": "0.1.4",
|
|
35
|
+
"@yozz.app/x509": "0.1.4",
|
|
36
|
+
"tsdown": "^0.23.0"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsdown src/index.ts --format esm --dts",
|