@yozz.app/imap 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/index.d.mts +60 -73
- package/dist/index.mjs +248 -234
- package/package.json +7 -8
package/README.md
CHANGED
|
@@ -36,3 +36,9 @@ Or for a specific host with authentication:
|
|
|
36
36
|
```bash
|
|
37
37
|
YOZZ_IMAP_HOST=imap.example.com YOZZ_IMAP_USER=me@example.com YOZZ_IMAP_PASSWORD=secret pnpm -F @yozz.app/imap live imap.example.com
|
|
38
38
|
```
|
|
39
|
+
|
|
40
|
+
A literal over 64 MiB (`DEFAULT_MAX_LITERAL_BYTES`, or `maxLiteralBytes` in the options) is a protocol failure.
|
|
41
|
+
|
|
42
|
+
`harness/relay-load.ts` pushes large messages through the hosted yozz.app relay, up over SMTP and
|
|
43
|
+
back down over IMAP, using a throwaway Forward Email alias and YOZZ account that it deletes. It is
|
|
44
|
+
for the maintainers; it needs `FORWARD_EMAIL_API_KEY` for the yozz.app domain.
|
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 64 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,80 +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[]>>;
|
|
257
|
-
/**
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
* which uids, sparse wherever mail has been deleted, cannot promise.
|
|
261
|
-
*/
|
|
262
|
-
readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>; /** UID FETCH FLAGS only — what a resync of already-known messages asks for. */
|
|
263
|
-
readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>; /** UID FETCH BODY.PEEK[] — the whole raw message. */
|
|
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[]>>;
|
|
264
249
|
readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
|
|
265
250
|
readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
|
|
266
|
-
/**
|
|
267
|
-
|
|
268
|
-
* Without `internalDate` the server stamps the message with its own clock.
|
|
269
|
-
*
|
|
270
|
-
* Resolves with the `APPENDUID` the server issued (RFC 4315), or `null` where it issued none —
|
|
271
|
-
* a server without UIDPLUS. That locator is how a caller addresses what it just wrote without
|
|
272
|
-
* searching for it, which matters for a draft: the alternative is finding it by Message-ID, and
|
|
273
|
-
* a retry after a lost response would then be indistinguishable from a duplicate.
|
|
274
|
-
*/
|
|
275
|
-
readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[], internalDate?: Date) => Promise<ImapResult<AppendUid | null>>; /** EXPUNGE: erases every `\\Deleted` message in the selected mailbox. */
|
|
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>>;
|
|
276
253
|
readonly expunge: () => Promise<ImapResult<void>>;
|
|
277
|
-
/**
|
|
278
|
-
* RFC 4315 UID EXPUNGE: erases only these `\\Deleted` messages, leaving anything another
|
|
279
|
-
* client flagged alone. Refuses without UIDPLUS rather than falling back to plain EXPUNGE,
|
|
280
|
-
* which would erase more than was asked.
|
|
281
|
-
*/
|
|
254
|
+
/** RFC 4315. Refuses without UIDPLUS rather than falling back to EXPUNGE, which erases more than asked. */
|
|
282
255
|
readonly uidExpunge: (uidSet: string) => Promise<ImapResult<void>>;
|
|
256
|
+
readonly uidSearchHeader: (header: string, value: string) => Promise<ImapResult<readonly number[]>>;
|
|
283
257
|
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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.
|
|
286
261
|
*/
|
|
287
|
-
readonly
|
|
288
|
-
|
|
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>>;
|
|
289
265
|
readonly create: (mailbox: string) => Promise<ImapResult<void>>;
|
|
290
266
|
readonly noop: () => Promise<ImapResult<void>>;
|
|
291
|
-
/**
|
|
292
|
-
* RFC 2177 IDLE. Occupies the command queue until `done()`: every other command waits
|
|
293
|
-
* behind it, so the caller MUST call `done()` before awaiting anything else. Untagged
|
|
294
|
-
* responses that arrive while idling (EXISTS, EXPUNGE, FETCH) go to `onUntagged`.
|
|
295
|
-
* Resolves once the server's tagged completion of the IDLE arrives after DONE.
|
|
296
|
-
*/
|
|
267
|
+
/** RFC 2177. Occupies the command queue until `done()`; untagged responses meanwhile go to `onUntagged`. */
|
|
297
268
|
readonly idle: () => ImapIdle;
|
|
298
269
|
readonly logout: () => Promise<ImapResult<void>>;
|
|
299
270
|
};
|
|
300
|
-
/**
|
|
271
|
+
/** RFC 4315 `[APPENDUID <uidvalidity> <uid>]`. */
|
|
301
272
|
type AppendUid = {
|
|
302
273
|
readonly uidValidity: number;
|
|
303
274
|
readonly uid: number;
|
|
304
275
|
};
|
|
305
|
-
|
|
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;
|
|
306
291
|
//#endregion
|
|
307
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;
|
|
308
301
|
/**
|
|
309
|
-
* RFC
|
|
310
|
-
*
|
|
311
|
-
* Decodes encoded-words in header fields (Q and B encodings).
|
|
312
|
-
* Crucial invariants:
|
|
313
|
-
* 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
|
|
314
|
-
* raw decoded bytes before charset decoding.
|
|
315
|
-
* 2. The output is NEVER re-scanned.
|
|
316
|
-
* 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.
|
|
317
304
|
*/
|
|
318
|
-
declare const
|
|
305
|
+
export declare const uidSets: (uids: Iterable<number>, maxLength?: number) => readonly string[];
|
|
319
306
|
//#endregion
|
|
320
|
-
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,21 +209,22 @@ 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`) }] };
|
|
236
228
|
};
|
|
237
229
|
const MONTHS = [
|
|
238
230
|
"Jan",
|
|
@@ -249,17 +241,12 @@ const MONTHS = [
|
|
|
249
241
|
"Dec"
|
|
250
242
|
];
|
|
251
243
|
const two = (value) => String(value).padStart(2, "0");
|
|
252
|
-
/**
|
|
253
|
-
* RFC 9051 date-time, quoted: `"28-Aug-2026 09:48:00 +0800"`. The day is space-padded rather
|
|
254
|
-
* than zero-padded, which the grammar requires and several servers enforce. Local zone, so it
|
|
255
|
-
* says when the message reached this client.
|
|
256
|
-
*/
|
|
244
|
+
/** RFC 9051 date-time. The day is space-padded, which the grammar requires and several servers enforce. */
|
|
257
245
|
const formatImapDateTime = (date) => {
|
|
258
246
|
const offset = -date.getTimezoneOffset();
|
|
259
247
|
const zone = `${offset < 0 ? "-" : "+"}${two(Math.floor(Math.abs(offset) / 60))}${two(Math.abs(offset) % 60)}`;
|
|
260
248
|
return `"${String(date.getDate()).padStart(2, " ")}-${MONTHS[date.getMonth()]}-${date.getFullYear()} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${zone}"`;
|
|
261
249
|
};
|
|
262
|
-
/** APPEND with the message as a literal; flags are IMAP atoms such as `\\Seen`, sent unquoted. */
|
|
263
250
|
const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
|
|
264
251
|
const mboxUtf7 = encodeModifiedUtf7(mailbox);
|
|
265
252
|
const stamp = internalDate === void 0 ? "" : `${formatImapDateTime(internalDate)} `;
|
|
@@ -268,32 +255,21 @@ const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
|
|
|
268
255
|
literal: message
|
|
269
256
|
}, { text: stringToBytes("\r\n") }] };
|
|
270
257
|
};
|
|
271
|
-
/** EXPUNGE: erases every `\\Deleted` message in the selected mailbox. */
|
|
272
258
|
const buildExpungeCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} EXPUNGE\r\n`) }] });
|
|
273
|
-
/**
|
|
274
|
-
* RFC 4315 UID EXPUNGE: erases only the named `\\Deleted` messages. Plain EXPUNGE erases every
|
|
275
|
-
* `\\Deleted` message in the mailbox, including ones another client flagged and has not yet
|
|
276
|
-
* erased itself — so replacing a draft with it would quietly take somebody else's deletions with
|
|
277
|
-
* it. Needs UIDPLUS; the client refuses without it rather than falling back.
|
|
278
|
-
*/
|
|
259
|
+
/** RFC 4315. */
|
|
279
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`) }] });
|
|
280
263
|
/**
|
|
281
|
-
* UID SEARCH
|
|
282
|
-
*
|
|
283
|
-
* What makes an APPEND retry safe: after a lost response the client asks whether the copy it was
|
|
284
|
-
* about to write is already there, instead of writing a second one.
|
|
285
|
-
*
|
|
286
|
-
* **Ask it about a header IMAP names.** A private `X-` header looks like the better question — a
|
|
287
|
-
* Message-ID can be rewritten by a provider, a subject match is a guess — but a server need only
|
|
288
|
-
* index the headers the protocol defines, and one that does not index yours answers the EMPTY LIST
|
|
289
|
-
* rather than an error. That is indistinguishable from "no copy is there", so the caller writes
|
|
290
|
-
* the second copy, or decides it has nothing to erase. Measured on Forward Email:
|
|
291
|
-
* docs/knowledge/forwardemail-api.md.
|
|
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.
|
|
292
266
|
*/
|
|
293
|
-
const
|
|
294
|
-
|
|
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. */
|
|
295
272
|
const buildMoveCommand = (tag, uidSet, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} UID MOVE ${uidSet} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
|
|
296
|
-
/** CREATE a mailbox (e.g. Archive the first time the client needs one). */
|
|
297
273
|
const buildCreateCommand = (tag, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} CREATE ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
|
|
298
274
|
const buildNoopCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} NOOP\r\n`) }] });
|
|
299
275
|
const buildIdleCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} IDLE\r\n`) }] });
|
|
@@ -302,16 +278,6 @@ const buildIdleDoneLine = () => stringToBytes("DONE\r\n");
|
|
|
302
278
|
const buildLogoutCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} LOGOUT\r\n`) }] });
|
|
303
279
|
//#endregion
|
|
304
280
|
//#region src/rfc2047.ts
|
|
305
|
-
/**
|
|
306
|
-
* RFC 2047 MIME Part Three: Message Header Extensions for Non-ASCII Text.
|
|
307
|
-
*
|
|
308
|
-
* Decodes encoded-words in header fields (Q and B encodings).
|
|
309
|
-
* Crucial invariants:
|
|
310
|
-
* 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
|
|
311
|
-
* raw decoded bytes before charset decoding.
|
|
312
|
-
* 2. The output is NEVER re-scanned.
|
|
313
|
-
* 3. Unknown charsets leave the raw word intact.
|
|
314
|
-
*/
|
|
315
281
|
const ENCODED_WORD_REGEX = /=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g;
|
|
316
282
|
const decodeQToBytes = (text) => {
|
|
317
283
|
const bytes = [];
|
|
@@ -423,7 +389,8 @@ const decodeRfc2047 = (header) => {
|
|
|
423
389
|
}
|
|
424
390
|
if (hasDecodeError) for (const w of sameCharsetWords) result += w.raw;
|
|
425
391
|
else {
|
|
426
|
-
const
|
|
392
|
+
const mergedBytes = concatByteArrays(byteChunks);
|
|
393
|
+
const decodedText = decodeBytesWithCharset(mergedBytes, firstInCharset.charset);
|
|
427
394
|
if (decodedText !== null) result += decodedText;
|
|
428
395
|
else for (const w of sameCharsetWords) result += w.raw;
|
|
429
396
|
}
|
|
@@ -439,9 +406,6 @@ const decodeRfc2047 = (header) => {
|
|
|
439
406
|
};
|
|
440
407
|
//#endregion
|
|
441
408
|
//#region src/envelope.ts
|
|
442
|
-
/**
|
|
443
|
-
* Parsers for IMAP FETCH items: ENVELOPE, FLAGS, INTERNALDATE, RFC822.SIZE, BODYSTRUCTURE.
|
|
444
|
-
*/
|
|
445
409
|
const tokenToString$1 = (token) => {
|
|
446
410
|
if (token === void 0 || token.kind === "nil") return null;
|
|
447
411
|
if (token.kind === "quoted" || token.kind === "atom") return token.value;
|
|
@@ -449,9 +413,7 @@ const tokenToString$1 = (token) => {
|
|
|
449
413
|
if (token.kind === "literal") return asciiToString(token.value);
|
|
450
414
|
return null;
|
|
451
415
|
};
|
|
452
|
-
/**
|
|
453
|
-
* Parses a single address tuple `(name adl mailbox host)` and advances index past `)`.
|
|
454
|
-
*/
|
|
416
|
+
/** RFC 9051 address: `(name adl mailbox host)`. */
|
|
455
417
|
const parseAddressTuple = (tokens, start) => {
|
|
456
418
|
let idx = start;
|
|
457
419
|
if (tokens[idx]?.kind !== "lparen") return null;
|
|
@@ -487,10 +449,6 @@ const parseAddressTuple = (tokens, start) => {
|
|
|
487
449
|
nextIndex: idx
|
|
488
450
|
};
|
|
489
451
|
};
|
|
490
|
-
/**
|
|
491
|
-
* Parses an address list `( (addr1) (addr2) ... )` or `NIL`.
|
|
492
|
-
* Flattening groups to their member addresses.
|
|
493
|
-
*/
|
|
494
452
|
const parseAddressList = (tokens, start) => {
|
|
495
453
|
const first = tokens[start];
|
|
496
454
|
if (first === void 0 || first.kind === "nil") return {
|
|
@@ -523,10 +481,7 @@ const parseAddressList = (tokens, start) => {
|
|
|
523
481
|
nextIndex: idx
|
|
524
482
|
};
|
|
525
483
|
};
|
|
526
|
-
/**
|
|
527
|
-
* Parses an ENVELOPE structure:
|
|
528
|
-
* `(date subject from sender reply-to to cc bcc in-reply-to message-id)`
|
|
529
|
-
*/
|
|
484
|
+
/** RFC 9051 envelope: `(date subject from sender reply-to to cc bcc in-reply-to message-id)`. */
|
|
530
485
|
const parseEnvelope = (tokens, start) => {
|
|
531
486
|
if (tokens[start]?.kind !== "lparen") return null;
|
|
532
487
|
let idx = start + 1;
|
|
@@ -576,9 +531,7 @@ const parseEnvelope = (tokens, start) => {
|
|
|
576
531
|
nextIndex: idx
|
|
577
532
|
};
|
|
578
533
|
};
|
|
579
|
-
/**
|
|
580
|
-
* Parses BODYSTRUCTURE, extracting only the list of MIME parts (e.g. ['TEXT/PLAIN', 'TEXT/HTML']).
|
|
581
|
-
*/
|
|
534
|
+
/** Only the MIME types, e.g. `['TEXT/PLAIN', 'TEXT/HTML']`. */
|
|
582
535
|
const parseBodyStructureParts = (tokens, start) => {
|
|
583
536
|
if (tokens[start]?.kind !== "lparen") return {
|
|
584
537
|
parts: [],
|
|
@@ -619,9 +572,6 @@ const parseBodyStructureParts = (tokens, start) => {
|
|
|
619
572
|
nextIndex: idx
|
|
620
573
|
};
|
|
621
574
|
};
|
|
622
|
-
/**
|
|
623
|
-
* Parses items inside a FETCH response `(...)`.
|
|
624
|
-
*/
|
|
625
575
|
const parseFetchItems = (tokens, start) => {
|
|
626
576
|
if (tokens[start]?.kind !== "lparen") return {
|
|
627
577
|
items: [],
|
|
@@ -661,21 +611,22 @@ const parseFetchItems = (tokens, start) => {
|
|
|
661
611
|
kind: "internalDate",
|
|
662
612
|
date: dateStr
|
|
663
613
|
});
|
|
664
|
-
} else if (itemName === "FLAGS")
|
|
665
|
-
idx
|
|
666
|
-
const flags = [];
|
|
667
|
-
while (idx < tokens.length && tokens[idx]?.kind !== "rparen") {
|
|
668
|
-
const flagTok = tokens[idx];
|
|
669
|
-
if (flagTok?.kind === "atom") flags.push(flagTok.value);
|
|
614
|
+
} else if (itemName === "FLAGS") {
|
|
615
|
+
if (tokens[idx]?.kind === "lparen") {
|
|
670
616
|
idx++;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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") {
|
|
679
630
|
const envResult = parseEnvelope(tokens, idx);
|
|
680
631
|
if (envResult !== null) {
|
|
681
632
|
items.push({
|
|
@@ -684,45 +635,46 @@ const parseFetchItems = (tokens, start) => {
|
|
|
684
635
|
});
|
|
685
636
|
idx = envResult.nextIndex;
|
|
686
637
|
} else idx++;
|
|
687
|
-
} else if (itemName === "BODYSTRUCTURE" || itemName === "BODY" || itemName === "BODY.PEEK")
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
idx
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (secTok
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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++;
|
|
706
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];
|
|
707
666
|
idx++;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
else
|
|
718
|
-
|
|
719
|
-
items.push({
|
|
720
|
-
kind: "body",
|
|
721
|
-
section,
|
|
722
|
-
bytes
|
|
723
|
-
});
|
|
724
|
-
} else idx++;
|
|
725
|
-
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") {
|
|
726
678
|
const id = tokenToString$1(tokens[idx]);
|
|
727
679
|
idx++;
|
|
728
680
|
if (id !== null && /^\d+$/.test(id)) items.push({
|
|
@@ -745,9 +697,6 @@ const parseFetchItems = (tokens, start) => {
|
|
|
745
697
|
};
|
|
746
698
|
//#endregion
|
|
747
699
|
//#region src/response.ts
|
|
748
|
-
/**
|
|
749
|
-
* IMAP response parser: converts token stream into typed tagged, untagged, and continuation responses.
|
|
750
|
-
*/
|
|
751
700
|
const tokenToString = (token) => {
|
|
752
701
|
if (token === void 0 || token.kind === "nil") return null;
|
|
753
702
|
if (token.kind === "quoted" || token.kind === "atom") return token.value;
|
|
@@ -995,6 +944,7 @@ const parseResponse = (tokens) => {
|
|
|
995
944
|
}
|
|
996
945
|
const delimiter = tokenToString(tokens[idx]);
|
|
997
946
|
idx++;
|
|
947
|
+
const rawName = tokenToString(tokens[idx]) ?? "";
|
|
998
948
|
return {
|
|
999
949
|
ok: true,
|
|
1000
950
|
value: {
|
|
@@ -1002,7 +952,7 @@ const parseResponse = (tokens) => {
|
|
|
1002
952
|
untagged: {
|
|
1003
953
|
kind: "list",
|
|
1004
954
|
mailbox: {
|
|
1005
|
-
name: decodeModifiedUtf7(
|
|
955
|
+
name: decodeModifiedUtf7(rawName),
|
|
1006
956
|
delimiter,
|
|
1007
957
|
attributes
|
|
1008
958
|
}
|
|
@@ -1133,22 +1083,8 @@ const parseResponse = (tokens) => {
|
|
|
1133
1083
|
};
|
|
1134
1084
|
//#endregion
|
|
1135
1085
|
//#region src/tokenizer.ts
|
|
1136
|
-
|
|
1137
|
-
* Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
|
|
1138
|
-
*
|
|
1139
|
-
* Implements total parsing over attacker-controlled bytes:
|
|
1140
|
-
* - Line ends at CRLF; bare LF is a protocol failure.
|
|
1141
|
-
* - Logical line frames `{n}` literals (and `{n+}` LITERAL+).
|
|
1142
|
-
* - Enforces maxLiteralBytes (default 32 MiB).
|
|
1143
|
-
* - A number above 2^32 - 1 is an atom of digits, not a failure: RFC 9051's `number` is
|
|
1144
|
-
* 32-bit, but Gmail's X-GM-THRID is 64-bit and arrives as a bare number.
|
|
1145
|
-
* - Never throws on malformed input; returns typed ImapResult.
|
|
1146
|
-
*/
|
|
1147
|
-
const DEFAULT_MAX_LITERAL_BYTES = 32 * 1024 * 1024;
|
|
1086
|
+
const DEFAULT_MAX_LITERAL_BYTES = 67108864;
|
|
1148
1087
|
const MAX_UINT32 = 4294967295;
|
|
1149
|
-
/**
|
|
1150
|
-
* Checks for bare LF in a slice of bytes.
|
|
1151
|
-
*/
|
|
1152
1088
|
const checkBareLf = (bytes, start, end) => {
|
|
1153
1089
|
for (let i = start; i < end; i++) if (bytes[i] === 10 && (i === 0 || bytes[i - 1] !== 13)) return {
|
|
1154
1090
|
kind: "protocol",
|
|
@@ -1156,10 +1092,6 @@ const checkBareLf = (bytes, start, end) => {
|
|
|
1156
1092
|
};
|
|
1157
1093
|
return null;
|
|
1158
1094
|
};
|
|
1159
|
-
/**
|
|
1160
|
-
* Parses literal length from `{<digits>}` or `{<digits>+}` before CRLF.
|
|
1161
|
-
* Returns valid length, invalid failure, or none if not a literal header.
|
|
1162
|
-
*/
|
|
1163
1095
|
const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
|
|
1164
1096
|
if (crlfIndex === 0) return { kind: "none" };
|
|
1165
1097
|
let i = crlfIndex - 1;
|
|
@@ -1197,9 +1129,6 @@ const parseLiteralHeader = (bytes, textStart, crlfIndex, maxLiteralBytes) => {
|
|
|
1197
1129
|
braceIndex
|
|
1198
1130
|
};
|
|
1199
1131
|
};
|
|
1200
|
-
/**
|
|
1201
|
-
* Scans `buffer` for a complete logical line (including any literals).
|
|
1202
|
-
*/
|
|
1203
1132
|
const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, resume) => {
|
|
1204
1133
|
const segments = resume?.segments ? [...resume.segments] : [];
|
|
1205
1134
|
let scanOffset = resume?.resumeAt ?? 0;
|
|
@@ -1307,9 +1236,6 @@ const readLogicalLine = (buffer, maxLiteralBytes = DEFAULT_MAX_LITERAL_BYTES, re
|
|
|
1307
1236
|
textStart
|
|
1308
1237
|
};
|
|
1309
1238
|
};
|
|
1310
|
-
/**
|
|
1311
|
-
* Tokenizes a text segment into IMAP tokens.
|
|
1312
|
-
*/
|
|
1313
1239
|
const tokenizeTextSegment = (bytes, tokens) => {
|
|
1314
1240
|
let i = 0;
|
|
1315
1241
|
const len = bytes.length;
|
|
@@ -1421,9 +1347,6 @@ const tokenizeTextSegment = (bytes, tokens) => {
|
|
|
1421
1347
|
value: void 0
|
|
1422
1348
|
};
|
|
1423
1349
|
};
|
|
1424
|
-
/**
|
|
1425
|
-
* Tokenizes a complete LogicalLine into ImapTokens.
|
|
1426
|
-
*/
|
|
1427
1350
|
const tokenizeLogicalLine = (line) => {
|
|
1428
1351
|
const tokens = [];
|
|
1429
1352
|
for (const seg of line.segments) if (seg.kind === "literal") tokens.push({
|
|
@@ -1441,21 +1364,6 @@ const tokenizeLogicalLine = (line) => {
|
|
|
1441
1364
|
};
|
|
1442
1365
|
//#endregion
|
|
1443
1366
|
//#region src/client.ts
|
|
1444
|
-
/**
|
|
1445
|
-
* IMAP client state machine over ByteDuplex.
|
|
1446
|
-
*
|
|
1447
|
-
* Invariants:
|
|
1448
|
-
* - One IMAP connection object per device (no connection pool).
|
|
1449
|
-
* - Transport is ByteDuplex from @yozz.app/tls.
|
|
1450
|
-
* - Parsing is total: parser never throws, returns typed ImapFailure and closes.
|
|
1451
|
-
* - Commands are serialised: one in-flight command at a time, queued in order (no pipelining in this slice).
|
|
1452
|
-
* - Greeting is captured immediately upon creation.
|
|
1453
|
-
*/
|
|
1454
|
-
/**
|
|
1455
|
-
* The `APPENDUID` of a tagged OK, when the server issued one. It arrives as an unrecognised
|
|
1456
|
-
* response code, which is exactly what `other` is for — no parser change, and a server without
|
|
1457
|
-
* UIDPLUS simply has nothing here.
|
|
1458
|
-
*/
|
|
1459
1367
|
const appendUidOf = (tagged) => {
|
|
1460
1368
|
const code = tagged.code;
|
|
1461
1369
|
if (code?.kind !== "other" || code.code !== "APPENDUID") return null;
|
|
@@ -1466,20 +1374,88 @@ const appendUidOf = (tagged) => {
|
|
|
1466
1374
|
uid
|
|
1467
1375
|
} : null;
|
|
1468
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
|
+
};
|
|
1469
1413
|
/**
|
|
1470
|
-
*
|
|
1471
|
-
*
|
|
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.
|
|
1472
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
|
+
};
|
|
1473
1453
|
const parseReferencesHeader = (bytes) => {
|
|
1474
1454
|
if (bytes === null) return [];
|
|
1475
1455
|
const line = asciiToString(bytes).replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/).find((l) => /^references:/i.test(l));
|
|
1476
1456
|
if (line === void 0) return [];
|
|
1477
1457
|
return line.slice(line.indexOf(":") + 1).match(/<[^<>\s]+>/g) ?? [];
|
|
1478
1458
|
};
|
|
1479
|
-
/**
|
|
1480
|
-
* The summaries in a FETCH response's untagged lines, whether the command asked by uid or by
|
|
1481
|
-
* sequence number: `* <seq> FETCH (UID n …)` carries both either way.
|
|
1482
|
-
*/
|
|
1483
1459
|
const summariesFrom = (untagged) => untagged.flatMap((item) => {
|
|
1484
1460
|
if (item.kind !== "fetch") return [];
|
|
1485
1461
|
let uid = 0;
|
|
@@ -1508,7 +1484,7 @@ const summariesFrom = (untagged) => untagged.flatMap((item) => {
|
|
|
1508
1484
|
}];
|
|
1509
1485
|
});
|
|
1510
1486
|
const createImapClient = (transport, options) => {
|
|
1511
|
-
const maxLiteralBytes = options?.maxLiteralBytes ??
|
|
1487
|
+
const maxLiteralBytes = options?.maxLiteralBytes ?? 67108864;
|
|
1512
1488
|
const onUntagged = options?.onUntagged;
|
|
1513
1489
|
let chunks = [];
|
|
1514
1490
|
let totalBufferedBytes = 0;
|
|
@@ -1518,9 +1494,6 @@ const createImapClient = (transport, options) => {
|
|
|
1518
1494
|
let knownCapabilities = [];
|
|
1519
1495
|
let tagCounter = 1;
|
|
1520
1496
|
const nextTag = () => `A${String(tagCounter++).padStart(4, "0")}`;
|
|
1521
|
-
/**
|
|
1522
|
-
* Reads and parses the next complete IMAP response from the transport.
|
|
1523
|
-
*/
|
|
1524
1497
|
const readNextResponse = async () => {
|
|
1525
1498
|
if (failureReason !== null) return {
|
|
1526
1499
|
ok: false,
|
|
@@ -1587,9 +1560,6 @@ const createImapClient = (transport, options) => {
|
|
|
1587
1560
|
totalBufferedBytes += chunk.length;
|
|
1588
1561
|
}
|
|
1589
1562
|
};
|
|
1590
|
-
/**
|
|
1591
|
-
* Greeting is captured immediately as soon as client is created.
|
|
1592
|
-
*/
|
|
1593
1563
|
const greetingPromise = (async () => {
|
|
1594
1564
|
const res = await readNextResponse();
|
|
1595
1565
|
if (!res.ok) return res;
|
|
@@ -1637,9 +1607,6 @@ const createImapClient = (transport, options) => {
|
|
|
1637
1607
|
reason: { kind: "closed" }
|
|
1638
1608
|
}));
|
|
1639
1609
|
};
|
|
1640
|
-
/**
|
|
1641
|
-
* Sends a command and collects untagged responses until matching tagged response.
|
|
1642
|
-
*/
|
|
1643
1610
|
const executeCommand = async (build, options) => {
|
|
1644
1611
|
if (isClosed) return {
|
|
1645
1612
|
ok: false,
|
|
@@ -1807,21 +1774,22 @@ const createImapClient = (transport, options) => {
|
|
|
1807
1774
|
text: resp.untagged.text
|
|
1808
1775
|
};
|
|
1809
1776
|
}
|
|
1810
|
-
} else if (resp.kind === "continuation")
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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") {
|
|
1825
1793
|
if (resp.tag !== tag) {
|
|
1826
1794
|
isClosed = true;
|
|
1827
1795
|
failureReason = {
|
|
@@ -1885,25 +1853,26 @@ const createImapClient = (transport, options) => {
|
|
|
1885
1853
|
const hasSaslIr = knownCapabilities.some((c) => c.toUpperCase() === "SASL-IR");
|
|
1886
1854
|
const hasLoginDisabled = knownCapabilities.some((c) => c.toUpperCase() === "LOGINDISABLED");
|
|
1887
1855
|
let authCmdRes;
|
|
1888
|
-
if (hasAuthPlain)
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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));
|
|
1907
1876
|
else return {
|
|
1908
1877
|
ok: false,
|
|
1909
1878
|
reason: {
|
|
@@ -2073,6 +2042,22 @@ const createImapClient = (transport, options) => {
|
|
|
2073
2042
|
value: res.value.untagged.flatMap((item) => item.kind === "search" ? [...item.uids] : [])
|
|
2074
2043
|
};
|
|
2075
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
|
+
}),
|
|
2076
2061
|
move: (uidSet, mailbox) => enqueueCommand(async () => {
|
|
2077
2062
|
const greetRes = await greetingPromise;
|
|
2078
2063
|
if (!greetRes.ok) return greetRes;
|
|
@@ -2090,7 +2075,7 @@ const createImapClient = (transport, options) => {
|
|
|
2090
2075
|
if (!res.ok) return res;
|
|
2091
2076
|
return {
|
|
2092
2077
|
ok: true,
|
|
2093
|
-
value:
|
|
2078
|
+
value: copyUidOf(res.value.untagged, res.value.tagged)
|
|
2094
2079
|
};
|
|
2095
2080
|
}),
|
|
2096
2081
|
create: (mailbox) => enqueueCommand(async () => {
|
|
@@ -2122,9 +2107,8 @@ const createImapClient = (transport, options) => {
|
|
|
2122
2107
|
}
|
|
2123
2108
|
let doneRequested = false;
|
|
2124
2109
|
let doneSent = false;
|
|
2125
|
-
/**
|
|
2110
|
+
/** Set once '+' arrived; only then may DONE be written. */
|
|
2126
2111
|
let isIdling = false;
|
|
2127
|
-
/** True once the IDLE has settled either way; a `done()` after that has nothing to end. */
|
|
2128
2112
|
let isEnded = false;
|
|
2129
2113
|
const sendDone = async () => {
|
|
2130
2114
|
if (doneSent || !isIdling || isEnded) return null;
|
|
@@ -2326,4 +2310,34 @@ const createImapClient = (transport, options) => {
|
|
|
2326
2310
|
};
|
|
2327
2311
|
};
|
|
2328
2312
|
//#endregion
|
|
2329
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yozz.app/imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
@@ -29,16 +29,15 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@types/node": "^24.
|
|
33
|
-
"
|
|
34
|
-
"@yozz.app/
|
|
35
|
-
"@yozz.app/
|
|
36
|
-
"
|
|
32
|
+
"@types/node": "^24.13.4",
|
|
33
|
+
"@yozz.app/smtp": "0.1.5",
|
|
34
|
+
"@yozz.app/tls": "0.1.5",
|
|
35
|
+
"@yozz.app/x509": "0.1.5",
|
|
36
|
+
"tsdown": "^0.23.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsdown src/index.ts --format esm --dts",
|
|
40
40
|
"test": "vitest run",
|
|
41
|
-
"live": "node harness/live.ts"
|
|
42
|
-
"seed": "node harness/seed-inbox.ts"
|
|
41
|
+
"live": "node harness/live.ts"
|
|
43
42
|
}
|
|
44
43
|
}
|