@yozz.app/imap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fishball Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @yozz.app/imap
2
+
3
+ Transport-agnostic IMAP4rev2/rev1 client core for YOZZ.
4
+
5
+ ```bash
6
+ pnpm add @yozz.app/imap
7
+ ```
8
+
9
+ ## The seam
10
+
11
+ `@yozz.app/imap` speaks IMAP over any `ByteDuplex` (`{ read(): Promise<Uint8Array | null>; write(bytes): Promise<void> }`)
12
+ from `@yozz.app/tls`. It knows protocol lines, `{n}` literals, command state, and RFC 2047 header
13
+ decoding. It **never knows** TLS records, certificates, session keys, or vault storage.
14
+
15
+ In the browser, the duplex wraps `@yozz.app/tls` over the production WebSocket relay. In tests, it
16
+ wraps in-memory transcript drivers without network. In the live harness, it connects through
17
+ `@yozz.app/tls` over TCP sockets.
18
+
19
+ ## Running tests
20
+
21
+ ```bash
22
+ pnpm -F @yozz.app/imap test
23
+ ```
24
+
25
+ ## Running the live harness
26
+
27
+ Tests real IMAP servers across the nine host matrix:
28
+
29
+ ```bash
30
+ pnpm -F @yozz.app/imap live
31
+ ```
32
+
33
+ Or for a specific host with authentication:
34
+
35
+ ```bash
36
+ 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
37
+ ```
@@ -0,0 +1,285 @@
1
+ import { ByteDuplex } from "@yozz.app/tls";
2
+
3
+ //#region src/tokenizer.d.ts
4
+ /**
5
+ * Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
6
+ *
7
+ * Implements total parsing over attacker-controlled bytes:
8
+ * - Line ends at CRLF; bare LF is a protocol failure.
9
+ * - Logical line frames `{n}` literals (and `{n+}` LITERAL+).
10
+ * - Enforces maxLiteralBytes (default 32 MiB).
11
+ * - A number above 2^32 - 1 is an atom of digits, not a failure: RFC 9051's `number` is
12
+ * 32-bit, but Gmail's X-GM-THRID is 64-bit and arrives as a bare number.
13
+ * - Never throws on malformed input; returns typed ImapResult.
14
+ */
15
+ declare const DEFAULT_MAX_LITERAL_BYTES: number;
16
+ type ImapToken = {
17
+ readonly kind: 'atom';
18
+ readonly value: string;
19
+ } | {
20
+ readonly kind: 'number';
21
+ readonly value: number;
22
+ } | {
23
+ readonly kind: 'quoted';
24
+ readonly value: string;
25
+ } | {
26
+ readonly kind: 'literal';
27
+ readonly value: Uint8Array;
28
+ } | {
29
+ readonly kind: 'lparen';
30
+ } | {
31
+ readonly kind: 'rparen';
32
+ } | {
33
+ readonly kind: 'lbracket';
34
+ } | {
35
+ readonly kind: 'rbracket';
36
+ } | {
37
+ readonly kind: 'plus';
38
+ } | {
39
+ readonly kind: 'nil';
40
+ };
41
+ type ImapFailure = {
42
+ readonly kind: 'no';
43
+ readonly text: string;
44
+ } | {
45
+ readonly kind: 'bad';
46
+ readonly text: string;
47
+ } | {
48
+ readonly kind: 'bye';
49
+ readonly text: string;
50
+ } | {
51
+ readonly kind: 'closed';
52
+ } | {
53
+ readonly kind: 'protocol';
54
+ readonly detail: string;
55
+ } | {
56
+ readonly kind: 'unsupported';
57
+ readonly detail: string;
58
+ };
59
+ type ImapResult<T> = {
60
+ readonly ok: true;
61
+ readonly value: T;
62
+ } | {
63
+ readonly ok: false;
64
+ readonly reason: ImapFailure;
65
+ };
66
+ //#endregion
67
+ //#region src/envelope.d.ts
68
+ type ImapAddress = {
69
+ readonly name: string | null;
70
+ readonly mailbox: string | null;
71
+ readonly host: string | null;
72
+ };
73
+ type ImapEnvelope = {
74
+ readonly date: string | null;
75
+ readonly subject: string | null;
76
+ readonly subjectRaw: string | null;
77
+ readonly from: readonly ImapAddress[];
78
+ readonly sender: readonly ImapAddress[];
79
+ readonly replyTo: readonly ImapAddress[];
80
+ readonly to: readonly ImapAddress[];
81
+ readonly cc: readonly ImapAddress[];
82
+ readonly bcc: readonly ImapAddress[];
83
+ readonly inReplyTo: string | null;
84
+ readonly messageId: string | null;
85
+ };
86
+ type ImapFetchItem = {
87
+ readonly kind: 'flags';
88
+ readonly flags: readonly string[];
89
+ } | {
90
+ readonly kind: 'envelope';
91
+ readonly envelope: ImapEnvelope;
92
+ } | {
93
+ readonly kind: 'internalDate';
94
+ readonly date: string;
95
+ } | {
96
+ readonly kind: 'size';
97
+ readonly size: number;
98
+ } | {
99
+ readonly kind: 'uid';
100
+ readonly uid: number;
101
+ } | {
102
+ readonly kind: 'body';
103
+ readonly section: string;
104
+ readonly bytes: Uint8Array | null;
105
+ } | {
106
+ readonly kind: 'bodyStructure';
107
+ readonly parts: readonly string[];
108
+ } /** Gmail's `X-GM-THRID`: 64-bit, so kept as its decimal digits. */ | {
109
+ readonly kind: 'gmailThreadId';
110
+ readonly id: string;
111
+ } | {
112
+ readonly kind: 'other';
113
+ readonly name: string;
114
+ };
115
+ //#endregion
116
+ //#region src/response.d.ts
117
+ type ImapMailbox = {
118
+ readonly name: string;
119
+ readonly delimiter: string | null;
120
+ readonly attributes: readonly string[];
121
+ };
122
+ type ImapResponseCode = {
123
+ readonly kind: 'capability';
124
+ readonly capabilities: readonly string[];
125
+ } | {
126
+ readonly kind: 'permanentFlags';
127
+ readonly flags: readonly string[];
128
+ } | {
129
+ readonly kind: 'uidValidity';
130
+ readonly value: number;
131
+ } | {
132
+ readonly kind: 'uidNext';
133
+ readonly value: number;
134
+ } | {
135
+ readonly kind: 'readOnly';
136
+ } | {
137
+ readonly kind: 'readWrite';
138
+ } | {
139
+ readonly kind: 'alert';
140
+ } | {
141
+ readonly kind: 'other';
142
+ readonly code: string;
143
+ readonly args: readonly string[];
144
+ };
145
+ type ImapUntagged = {
146
+ readonly kind: 'status';
147
+ readonly status: 'OK' | 'NO' | 'BAD' | 'BYE' | 'PREAUTH';
148
+ readonly code: ImapResponseCode | null;
149
+ readonly text: string;
150
+ } | {
151
+ readonly kind: 'capability';
152
+ readonly capabilities: readonly string[];
153
+ } | {
154
+ readonly kind: 'list';
155
+ readonly mailbox: ImapMailbox;
156
+ } | {
157
+ readonly kind: 'flags';
158
+ readonly flags: readonly string[];
159
+ } | {
160
+ readonly kind: 'exists';
161
+ readonly count: number;
162
+ } | {
163
+ readonly kind: 'recent';
164
+ readonly count: number;
165
+ } | {
166
+ readonly kind: 'expunge';
167
+ readonly seq: number;
168
+ } | {
169
+ readonly kind: 'fetch';
170
+ readonly seq: number;
171
+ readonly items: readonly ImapFetchItem[];
172
+ } | {
173
+ readonly kind: 'search';
174
+ readonly uids: readonly number[];
175
+ } | {
176
+ readonly kind: 'unknown';
177
+ readonly name: string;
178
+ readonly rest: readonly string[];
179
+ };
180
+ type ImapTagged = {
181
+ readonly kind: 'tagged';
182
+ readonly tag: string;
183
+ readonly status: 'OK' | 'NO' | 'BAD';
184
+ readonly code: ImapResponseCode | null;
185
+ readonly text: string;
186
+ };
187
+ type ImapContinuation = {
188
+ readonly kind: 'continuation';
189
+ readonly text: string;
190
+ };
191
+ type ImapResponse = ImapTagged | {
192
+ readonly kind: 'untagged';
193
+ readonly untagged: ImapUntagged;
194
+ } | ImapContinuation;
195
+ declare const parseResponse: (tokens: readonly ImapToken[]) => ImapResult<ImapResponse>;
196
+ //#endregion
197
+ //#region src/client.d.ts
198
+ type ImapMessageSummary = {
199
+ readonly seq: number;
200
+ readonly uid: number;
201
+ readonly flags: readonly string[];
202
+ readonly internalDate: string | null;
203
+ readonly size: number | null;
204
+ readonly envelope: ImapEnvelope | null; /** The msg-ids of the `References` header, in order; empty when the message carries none. */
205
+ readonly references: readonly string[]; /** Gmail's thread id (decimal digits), when the server was asked and answered. */
206
+ readonly gmailThreadId: string | null;
207
+ };
208
+ type ImapMessageFlags = {
209
+ readonly uid: number;
210
+ readonly flags: readonly string[];
211
+ };
212
+ type ImapSelected = {
213
+ readonly name: string;
214
+ readonly exists: number;
215
+ readonly uidValidity: number | null;
216
+ readonly uidNext: number | null;
217
+ readonly flags: readonly string[];
218
+ readonly permanentFlags: readonly string[];
219
+ readonly readOnly: boolean;
220
+ };
221
+ type ImapClientOptions = {
222
+ readonly onUntagged?: (response: ImapUntagged) => void; /** Max bytes of a single literal the client will buffer; default 32 MiB. Larger → protocol failure. */
223
+ readonly maxLiteralBytes?: number;
224
+ };
225
+ type ImapIdle = {
226
+ /** Sends DONE (once; later calls are no-ops) and resolves with the IDLE's outcome. */readonly done: () => Promise<ImapResult<void>>; /** The same outcome, for a caller that wants to observe an idle ending on its own (BYE, EOF). */
227
+ readonly ended: Promise<ImapResult<void>>;
228
+ };
229
+ type ImapClient = {
230
+ readonly greeting: () => Promise<ImapResult<{
231
+ readonly text: string;
232
+ readonly capabilities: readonly string[] | null;
233
+ }>>;
234
+ readonly capability: () => Promise<ImapResult<readonly string[]>>;
235
+ readonly capabilities: () => readonly string[]; /** Case-insensitive check against the last known capability list. */
236
+ readonly hasCapability: (name: string) => boolean;
237
+ readonly authenticate: (username: string, password: string) => Promise<ImapResult<void>>;
238
+ readonly list: (reference: string, pattern: string) => Promise<ImapResult<readonly ImapMailbox[]>>;
239
+ readonly select: (mailbox: string, options?: {
240
+ readonly readOnly?: boolean;
241
+ }) => Promise<ImapResult<ImapSelected>>;
242
+ /**
243
+ * UID FETCH with FLAGS ENVELOPE INTERNALDATE RFC822.SIZE, the `References` header and, on a
244
+ * server advertising `X-GM-EXT-1`, `X-GM-THRID`. `set` is an IMAP sequence-set string, e.g.
245
+ * "1:*" or "100:200".
246
+ */
247
+ readonly fetchSummaries: (uidSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>;
248
+ /**
249
+ * The same FETCH read by message SEQUENCE number rather than uid. Sequence numbers are dense
250
+ * (1..EXISTS in the selected mailbox), so a fixed-width window is that many real messages —
251
+ * which uids, sparse wherever mail has been deleted, cannot promise.
252
+ */
253
+ readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>; /** UID FETCH FLAGS only — what a resync of already-known messages asks for. */
254
+ readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>; /** UID FETCH BODY.PEEK[] — the whole raw message. */
255
+ readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
256
+ 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. */
257
+ 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). */
258
+ readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<void>>; /** CREATE a mailbox. */
259
+ readonly create: (mailbox: string) => Promise<ImapResult<void>>;
260
+ readonly noop: () => Promise<ImapResult<void>>;
261
+ /**
262
+ * RFC 2177 IDLE. Occupies the command queue until `done()`: every other command waits
263
+ * behind it, so the caller MUST call `done()` before awaiting anything else. Untagged
264
+ * responses that arrive while idling (EXISTS, EXPUNGE, FETCH) go to `onUntagged`.
265
+ * Resolves once the server's tagged completion of the IDLE arrives after DONE.
266
+ */
267
+ readonly idle: () => ImapIdle;
268
+ readonly logout: () => Promise<ImapResult<void>>;
269
+ };
270
+ declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOptions) => ImapClient;
271
+ //#endregion
272
+ //#region src/rfc2047.d.ts
273
+ /**
274
+ * RFC 2047 MIME Part Three: Message Header Extensions for Non-ASCII Text.
275
+ *
276
+ * Decodes encoded-words in header fields (Q and B encodings).
277
+ * Crucial invariants:
278
+ * 1. Adjacent encoded-words separated only by linear-white-space (LWS) are joined by their
279
+ * raw decoded bytes before charset decoding.
280
+ * 2. The output is NEVER re-scanned.
281
+ * 3. Unknown charsets leave the raw word intact.
282
+ */
283
+ declare const decodeRfc2047: (header: string) => string;
284
+ //#endregion
285
+ export { DEFAULT_MAX_LITERAL_BYTES, type ImapAddress, type ImapClient, type ImapClientOptions, type ImapContinuation, type ImapEnvelope, type ImapFailure, type ImapFetchItem, type ImapIdle, type ImapMailbox, type ImapMessageSummary, type ImapResponse, type ImapResponseCode, type ImapResult, type ImapSelected, type ImapTagged, type ImapToken, type ImapUntagged, createImapClient, decodeRfc2047, parseResponse };