@yozz.app/imap 0.1.2 → 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/dist/index.d.mts +28 -2
- package/dist/index.mjs +102 -4
- package/package.json +7 -5
package/dist/index.d.mts
CHANGED
|
@@ -262,8 +262,29 @@ type ImapClient = {
|
|
|
262
262
|
readonly fetchSummariesBySeq: (seqSet: string) => Promise<ImapResult<readonly ImapMessageSummary[]>>; /** UID FETCH FLAGS only — what a resync of already-known messages asks for. */
|
|
263
263
|
readonly fetchFlags: (uidSet: string) => Promise<ImapResult<readonly ImapMessageFlags[]>>; /** UID FETCH BODY.PEEK[] — the whole raw message. */
|
|
264
264
|
readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
|
|
265
|
-
readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
|
|
266
|
-
|
|
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). */
|
|
267
288
|
readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<void>>; /** CREATE a mailbox. */
|
|
268
289
|
readonly create: (mailbox: string) => Promise<ImapResult<void>>;
|
|
269
290
|
readonly noop: () => Promise<ImapResult<void>>;
|
|
@@ -276,6 +297,11 @@ type ImapClient = {
|
|
|
276
297
|
readonly idle: () => ImapIdle;
|
|
277
298
|
readonly logout: () => Promise<ImapResult<void>>;
|
|
278
299
|
};
|
|
300
|
+
/** Where an APPEND landed: RFC 4315's `[APPENDUID <uidvalidity> <uid>]`. */
|
|
301
|
+
type AppendUid = {
|
|
302
|
+
readonly uidValidity: number;
|
|
303
|
+
readonly uid: number;
|
|
304
|
+
};
|
|
279
305
|
declare const createImapClient: (transport: ByteDuplex, options?: ImapClientOptions) => ImapClient;
|
|
280
306
|
//#endregion
|
|
281
307
|
//#region src/rfc2047.d.ts
|
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). */
|
|
@@ -1402,6 +1452,21 @@ const tokenizeLogicalLine = (line) => {
|
|
|
1402
1452
|
* - Greeting is captured immediately upon creation.
|
|
1403
1453
|
*/
|
|
1404
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
|
+
/**
|
|
1405
1470
|
* The msg-ids out of a `HEADER.FIELDS (REFERENCES)` section: the header unfolded, then every
|
|
1406
1471
|
* `<...>` in order. A truncated or absent header is simply fewer ids.
|
|
1407
1472
|
*/
|
|
@@ -1967,14 +2032,47 @@ const createImapClient = (transport, options) => {
|
|
|
1967
2032
|
value: void 0
|
|
1968
2033
|
};
|
|
1969
2034
|
}),
|
|
1970
|
-
append: (mailbox, message, flags) => enqueueCommand(async () => {
|
|
1971
|
-
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);
|
|
1972
2045
|
if (!res.ok) return res;
|
|
1973
2046
|
return {
|
|
1974
2047
|
ok: true,
|
|
1975
2048
|
value: void 0
|
|
1976
2049
|
};
|
|
1977
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));
|
|
2062
|
+
if (!res.ok) return res;
|
|
2063
|
+
return {
|
|
2064
|
+
ok: true,
|
|
2065
|
+
value: void 0
|
|
2066
|
+
};
|
|
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
|
+
}),
|
|
1978
2076
|
move: (uidSet, mailbox) => enqueueCommand(async () => {
|
|
1979
2077
|
const greetRes = await greetingPromise;
|
|
1980
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": {
|
|
@@ -31,12 +31,14 @@
|
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^24.0.0",
|
|
33
33
|
"tsdown": "^0.22.3",
|
|
34
|
-
"@yozz.app/
|
|
35
|
-
"@yozz.app/
|
|
34
|
+
"@yozz.app/smtp": "0.1.3",
|
|
35
|
+
"@yozz.app/tls": "0.1.3",
|
|
36
|
+
"@yozz.app/x509": "0.1.3"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsdown src/index.ts --format esm --dts",
|
|
39
40
|
"test": "vitest run",
|
|
40
|
-
"live": "node harness/live.ts"
|
|
41
|
+
"live": "node harness/live.ts",
|
|
42
|
+
"seed": "node harness/seed-inbox.ts"
|
|
41
43
|
}
|
|
42
44
|
}
|