@yozz.app/imap 0.1.1 → 0.1.3
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 +3 -2
- package/dist/index.d.mts +40 -5
- package/dist/index.mjs +112 -4
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -8,8 +8,9 @@ pnpm add @yozz.app/imap
|
|
|
8
8
|
|
|
9
9
|
## The seam
|
|
10
10
|
|
|
11
|
-
`@yozz.app/imap` speaks IMAP over any `ByteDuplex` (`{ read(): Promise<Uint8Array | null>; write(bytes): Promise<void> }`)
|
|
12
|
-
|
|
11
|
+
`@yozz.app/imap` speaks IMAP over any `ByteDuplex` (`{ read(): Promise<Uint8Array | null>; write(bytes): Promise<void> }`),
|
|
12
|
+
a type it declares itself; a `@yozz.app/tls` connection satisfies it, and so does anything else with
|
|
13
|
+
those two methods. The package has no runtime dependencies. It knows protocol lines, `{n}` literals, command state, and RFC 2047 header
|
|
13
14
|
decoding. It **never knows** TLS records, certificates, session keys, or vault storage.
|
|
14
15
|
|
|
15
16
|
In the browser, the duplex wraps `@yozz.app/tls` over the production WebSocket relay. In tests, it
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { ByteDuplex } from "@yozz.app/tls";
|
|
2
|
-
|
|
3
1
|
//#region src/tokenizer.d.ts
|
|
4
2
|
/**
|
|
5
3
|
* Hand-written tokenizer over byte offsets for IMAP4rev2/rev1 stream.
|
|
@@ -194,6 +192,17 @@ type ImapResponse = ImapTagged | {
|
|
|
194
192
|
} | ImapContinuation;
|
|
195
193
|
declare const parseResponse: (tokens: readonly ImapToken[]) => ImapResult<ImapResponse>;
|
|
196
194
|
//#endregion
|
|
195
|
+
//#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
|
+
type ByteDuplex = {
|
|
202
|
+
readonly read: () => Promise<Uint8Array | null>;
|
|
203
|
+
readonly write: (bytes: Uint8Array) => Promise<void>;
|
|
204
|
+
};
|
|
205
|
+
//#endregion
|
|
197
206
|
//#region src/client.d.ts
|
|
198
207
|
type ImapMessageSummary = {
|
|
199
208
|
readonly seq: number;
|
|
@@ -253,8 +262,29 @@ type ImapClient = {
|
|
|
253
262
|
readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>; /** UID FETCH FLAGS only — what a resync of already-known messages asks for. */
|
|
254
263
|
readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>; /** UID FETCH BODY.PEEK[] — the whole raw message. */
|
|
255
264
|
readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
|
|
256
|
-
readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
|
|
257
|
-
|
|
265
|
+
readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
|
|
266
|
+
/**
|
|
267
|
+
* APPEND a whole RFC 5322 message to a mailbox, e.g. a Sent copy after SMTP accepted it.
|
|
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. */
|
|
276
|
+
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
|
+
*/
|
|
282
|
+
readonly uidExpunge: (uidSet: string) => Promise<ImapResult<void>>;
|
|
283
|
+
/**
|
|
284
|
+
* UID SEARCH over one header's exact value in the selected mailbox, newest-last uid order as
|
|
285
|
+
* the server gives it. An empty array means the mailbox does not hold it.
|
|
286
|
+
*/
|
|
287
|
+
readonly uidSearchHeader: (header: string, value: string) => Promise<ImapResult<readonly number[]>>; /** RFC 6851 UID MOVE. Refuses without the MOVE capability (no COPY+EXPUNGE fallback). */
|
|
258
288
|
readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<void>>; /** CREATE a mailbox. */
|
|
259
289
|
readonly create: (mailbox: string) => Promise<ImapResult<void>>;
|
|
260
290
|
readonly noop: () => Promise<ImapResult<void>>;
|
|
@@ -267,6 +297,11 @@ type ImapClient = {
|
|
|
267
297
|
readonly idle: () => ImapIdle;
|
|
268
298
|
readonly logout: () => Promise<ImapResult<void>>;
|
|
269
299
|
};
|
|
300
|
+
/** Where an APPEND landed: RFC 4315's `[APPENDUID <uidvalidity> <uid>]`. */
|
|
301
|
+
type AppendUid = {
|
|
302
|
+
readonly uidValidity: number;
|
|
303
|
+
readonly uid: number;
|
|
304
|
+
};
|
|
270
305
|
declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOptions) => ImapClient;
|
|
271
306
|
//#endregion
|
|
272
307
|
//#region src/rfc2047.d.ts
|
|
@@ -282,4 +317,4 @@ declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOpti
|
|
|
282
317
|
*/
|
|
283
318
|
declare const decodeRfc2047: (header: string) => string;
|
|
284
319
|
//#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 };
|
|
320
|
+
export { type ByteDuplex, 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 };
|
package/dist/index.mjs
CHANGED
|
@@ -234,13 +234,63 @@ const buildFetchRawCommand = (tag, uid) => ({ lines: [{ text: stringToBytes(`${t
|
|
|
234
234
|
const buildStoreFlagsCommand = (tag, uidSet, mode, flags) => {
|
|
235
235
|
return { lines: [{ text: stringToBytes(`${tag} UID STORE ${uidSet} ${mode === "add" ? "+FLAGS" : mode === "remove" ? "-FLAGS" : "FLAGS"} (${flags.join(" ")})\r\n`) }] };
|
|
236
236
|
};
|
|
237
|
+
const MONTHS = [
|
|
238
|
+
"Jan",
|
|
239
|
+
"Feb",
|
|
240
|
+
"Mar",
|
|
241
|
+
"Apr",
|
|
242
|
+
"May",
|
|
243
|
+
"Jun",
|
|
244
|
+
"Jul",
|
|
245
|
+
"Aug",
|
|
246
|
+
"Sep",
|
|
247
|
+
"Oct",
|
|
248
|
+
"Nov",
|
|
249
|
+
"Dec"
|
|
250
|
+
];
|
|
251
|
+
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
|
+
*/
|
|
257
|
+
const formatImapDateTime = (date) => {
|
|
258
|
+
const offset = -date.getTimezoneOffset();
|
|
259
|
+
const zone = `${offset < 0 ? "-" : "+"}${two(Math.floor(Math.abs(offset) / 60))}${two(Math.abs(offset) % 60)}`;
|
|
260
|
+
return `"${String(date.getDate()).padStart(2, " ")}-${MONTHS[date.getMonth()]}-${date.getFullYear()} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${zone}"`;
|
|
261
|
+
};
|
|
237
262
|
/** APPEND with the message as a literal; flags are IMAP atoms such as `\\Seen`, sent unquoted. */
|
|
238
|
-
const buildAppendCommand = (tag, mailbox, flags, message) => {
|
|
263
|
+
const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
|
|
264
|
+
const mboxUtf7 = encodeModifiedUtf7(mailbox);
|
|
265
|
+
const stamp = internalDate === void 0 ? "" : `${formatImapDateTime(internalDate)} `;
|
|
239
266
|
return { lines: [{
|
|
240
|
-
text: stringToBytes(`${tag} APPEND ${quoteString(
|
|
267
|
+
text: stringToBytes(`${tag} APPEND ${quoteString(mboxUtf7)} (${flags.join(" ")}) ${stamp}`),
|
|
241
268
|
literal: message
|
|
242
269
|
}, { text: stringToBytes("\r\n") }] };
|
|
243
270
|
};
|
|
271
|
+
/** EXPUNGE: erases every `\\Deleted` message in the selected mailbox. */
|
|
272
|
+
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
|
+
*/
|
|
279
|
+
const buildUidExpungeCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID EXPUNGE ${uidSet}\r\n`) }] });
|
|
280
|
+
/**
|
|
281
|
+
* UID SEARCH for one header's exact value, e.g. `HEADER "Message-ID" "<id>"`.
|
|
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.
|
|
292
|
+
*/
|
|
293
|
+
const buildUidSearchHeaderCommand = (tag, header, value) => ({ lines: [{ text: stringToBytes(`${tag} UID SEARCH HEADER ${quoteString(header)} ${quoteString(value)}\r\n`) }] });
|
|
244
294
|
/** RFC 6851 UID MOVE — relocates messages into another mailbox in one round trip. */
|
|
245
295
|
const buildMoveCommand = (tag, uidSet, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} UID MOVE ${uidSet} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
|
|
246
296
|
/** CREATE a mailbox (e.g. Archive the first time the client needs one). */
|
|
@@ -1392,6 +1442,31 @@ const tokenizeLogicalLine = (line) => {
|
|
|
1392
1442
|
//#endregion
|
|
1393
1443
|
//#region src/client.ts
|
|
1394
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
|
+
const appendUidOf = (tagged) => {
|
|
1460
|
+
const code = tagged.code;
|
|
1461
|
+
if (code?.kind !== "other" || code.code !== "APPENDUID") return null;
|
|
1462
|
+
const [uidValidity, uid] = code.args.map(Number);
|
|
1463
|
+
if (uidValidity === void 0 || uid === void 0) return null;
|
|
1464
|
+
return Number.isInteger(uidValidity) && Number.isInteger(uid) ? {
|
|
1465
|
+
uidValidity,
|
|
1466
|
+
uid
|
|
1467
|
+
} : null;
|
|
1468
|
+
};
|
|
1469
|
+
/**
|
|
1395
1470
|
* The msg-ids out of a `HEADER.FIELDS (REFERENCES)` section: the header unfolded, then every
|
|
1396
1471
|
* `<...>` in order. A truncated or absent header is simply fewer ids.
|
|
1397
1472
|
*/
|
|
@@ -1957,14 +2032,47 @@ const createImapClient = (transport, options) => {
|
|
|
1957
2032
|
value: void 0
|
|
1958
2033
|
};
|
|
1959
2034
|
}),
|
|
1960
|
-
append: (mailbox, message, flags) => enqueueCommand(async () => {
|
|
1961
|
-
const res = await executeCommand((tag) => buildAppendCommand(tag, mailbox, flags, message));
|
|
2035
|
+
append: (mailbox, message, flags, internalDate) => enqueueCommand(async () => {
|
|
2036
|
+
const res = await executeCommand((tag) => buildAppendCommand(tag, mailbox, flags, message, internalDate));
|
|
2037
|
+
if (!res.ok) return res;
|
|
2038
|
+
return {
|
|
2039
|
+
ok: true,
|
|
2040
|
+
value: appendUidOf(res.value.tagged)
|
|
2041
|
+
};
|
|
2042
|
+
}),
|
|
2043
|
+
expunge: () => enqueueCommand(async () => {
|
|
2044
|
+
const res = await executeCommand(buildExpungeCommand);
|
|
2045
|
+
if (!res.ok) return res;
|
|
2046
|
+
return {
|
|
2047
|
+
ok: true,
|
|
2048
|
+
value: void 0
|
|
2049
|
+
};
|
|
2050
|
+
}),
|
|
2051
|
+
uidExpunge: (uidSet) => enqueueCommand(async () => {
|
|
2052
|
+
const greetRes = await greetingPromise;
|
|
2053
|
+
if (!greetRes.ok) return greetRes;
|
|
2054
|
+
if (!knownCapabilities.some((c) => c.toUpperCase() === "UIDPLUS")) return {
|
|
2055
|
+
ok: false,
|
|
2056
|
+
reason: {
|
|
2057
|
+
kind: "no",
|
|
2058
|
+
text: "UID EXPUNGE needs UIDPLUS, which this server lacks"
|
|
2059
|
+
}
|
|
2060
|
+
};
|
|
2061
|
+
const res = await executeCommand((tag) => buildUidExpungeCommand(tag, uidSet));
|
|
1962
2062
|
if (!res.ok) return res;
|
|
1963
2063
|
return {
|
|
1964
2064
|
ok: true,
|
|
1965
2065
|
value: void 0
|
|
1966
2066
|
};
|
|
1967
2067
|
}),
|
|
2068
|
+
uidSearchHeader: (header, value) => enqueueCommand(async () => {
|
|
2069
|
+
const res = await executeCommand((tag) => buildUidSearchHeaderCommand(tag, header, value));
|
|
2070
|
+
if (!res.ok) return res;
|
|
2071
|
+
return {
|
|
2072
|
+
ok: true,
|
|
2073
|
+
value: res.value.untagged.flatMap((item) => item.kind === "search" ? [...item.uids] : [])
|
|
2074
|
+
};
|
|
2075
|
+
}),
|
|
1968
2076
|
move: (uidSet, mailbox) => enqueueCommand(async () => {
|
|
1969
2077
|
const greetRes = await greetingPromise;
|
|
1970
2078
|
if (!greetRes.ok) return greetRes;
|
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.3",
|
|
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": {
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
|
-
"dependencies": {
|
|
32
|
-
"@yozz.app/tls": "0.1.1"
|
|
33
|
-
},
|
|
34
31
|
"devDependencies": {
|
|
35
32
|
"@types/node": "^24.0.0",
|
|
36
33
|
"tsdown": "^0.22.3",
|
|
37
|
-
"@yozz.app/
|
|
34
|
+
"@yozz.app/smtp": "0.1.3",
|
|
35
|
+
"@yozz.app/tls": "0.1.3",
|
|
36
|
+
"@yozz.app/x509": "0.1.3"
|
|
38
37
|
},
|
|
39
38
|
"scripts": {
|
|
40
39
|
"build": "tsdown src/index.ts --format esm --dts",
|
|
41
40
|
"test": "vitest run",
|
|
42
|
-
"live": "node harness/live.ts"
|
|
41
|
+
"live": "node harness/live.ts",
|
|
42
|
+
"seed": "node harness/seed-inbox.ts"
|
|
43
43
|
}
|
|
44
44
|
}
|