@yozz.app/imap 0.2.0 → 0.3.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/README.md +9 -0
- package/dist/index.d.mts +14 -12
- package/dist/index.mjs +62 -21
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -37,6 +37,15 @@ Or for a specific host with authentication:
|
|
|
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
39
|
|
|
40
|
+
With credentials it also checks, read-only (`UID SEARCH` and `FETCH` only), that one `UID SEARCH` of several keys
|
|
41
|
+
answers the uids its keys answer apart, intersected: `ok` or `MISMATCH` per command. A slow server
|
|
42
|
+
(Forward Email takes 25 s or more a search) makes it take minutes.
|
|
43
|
+
|
|
44
|
+
**Requires a global `Temporal`.** `append` stamps a `Temporal.ZonedDateTime` (written in its own zone), and
|
|
45
|
+
`UID SEARCH` takes `BEFORE` / `SINCE` days as `Temporal.PlainDate`s. Node 26, Chrome and Firefox ship it; where the runtime has none (Safari, Node 24), install
|
|
46
|
+
[`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill) and load it before this package is
|
|
47
|
+
imported (`import 'temporal-polyfill/global'`). The package imports no polyfill itself.
|
|
48
|
+
|
|
40
49
|
A literal over 64 MiB (`DEFAULT_MAX_LITERAL_BYTES`, or `maxLiteralBytes` in the options) is a protocol failure.
|
|
41
50
|
|
|
42
51
|
`harness/relay-load.ts` pushes large messages through the hosted yozz.app relay, up over SMTP and
|
package/dist/index.d.mts
CHANGED
|
@@ -19,17 +19,13 @@ type UidSearchCriterion =
|
|
|
19
19
|
/** By INTERNALDATE's day: `SINCE` is on or after it, `BEFORE` strictly before it. */
|
|
20
20
|
{
|
|
21
21
|
readonly key: 'BEFORE';
|
|
22
|
-
readonly date:
|
|
22
|
+
readonly date: Temporal.PlainDate;
|
|
23
23
|
} | {
|
|
24
24
|
readonly key: 'SINCE';
|
|
25
|
-
readonly date:
|
|
26
|
-
};
|
|
27
|
-
/** A calendar day; `month` runs 1 to 12. */
|
|
28
|
-
type ImapSearchDate = {
|
|
29
|
-
readonly year: number;
|
|
30
|
-
readonly month: number;
|
|
31
|
-
readonly day: number;
|
|
25
|
+
readonly date: Temporal.PlainDate;
|
|
32
26
|
};
|
|
27
|
+
/** One key, or several that a message must all meet. */
|
|
28
|
+
type UidSearchCriteria = UidSearchCriterion | readonly UidSearchCriterion[];
|
|
33
29
|
//#endregion
|
|
34
30
|
//#region src/tokenizer.d.ts
|
|
35
31
|
export declare const DEFAULT_MAX_LITERAL_BYTES: number;
|
|
@@ -241,6 +237,10 @@ type ImapMessageFlags = {
|
|
|
241
237
|
readonly uid: number;
|
|
242
238
|
readonly flags: readonly string[];
|
|
243
239
|
};
|
|
240
|
+
type ImapRawMessage = {
|
|
241
|
+
readonly uid: number;
|
|
242
|
+
readonly bytes: Uint8Array;
|
|
243
|
+
};
|
|
244
244
|
type ImapMessageAttachmentNames = {
|
|
245
245
|
readonly uid: number;
|
|
246
246
|
readonly attachmentNames: readonly string[];
|
|
@@ -288,14 +288,16 @@ type ImapClient = {
|
|
|
288
288
|
/** Just the file names of `fetchSummaries`, for summaries read without them. Marks nothing read. */
|
|
289
289
|
readonly fetchAttachmentNames: (uidSet: string) => Promise<ImapResult<readonly ImapMessageAttachmentNames[]>>;
|
|
290
290
|
readonly fetchRaw: (uid: number) => Promise<ImapResult<Uint8Array>>;
|
|
291
|
+
/** Many messages whole in one command; a uid the server no longer has is simply absent. */
|
|
292
|
+
readonly fetchRawMessages: (uidSet: string) => Promise<ImapResult<readonly ImapRawMessage[]>>;
|
|
291
293
|
readonly storeFlags: (uidSet: string, mode: 'add' | 'remove' | 'set', flags: readonly string[]) => Promise<ImapResult<void>>;
|
|
292
294
|
/** Resolves with the RFC 4315 `APPENDUID`, or `null` from a server without UIDPLUS. */
|
|
293
|
-
readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[], internalDate?:
|
|
295
|
+
readonly append: (mailbox: string, message: Uint8Array, flags: readonly string[], internalDate?: Temporal.ZonedDateTime) => Promise<ImapResult<AppendUid | null>>;
|
|
294
296
|
readonly expunge: () => Promise<ImapResult<void>>;
|
|
295
297
|
/** RFC 4315. Refuses without UIDPLUS rather than falling back to EXPUNGE, which erases more than asked. */
|
|
296
298
|
readonly uidExpunge: (uidSet: string) => Promise<ImapResult<void>>;
|
|
297
|
-
/** The selected mailbox's uids matching one
|
|
298
|
-
readonly uidSearch: (
|
|
299
|
+
/** The selected mailbox's uids matching every one of these keys, ascending. */
|
|
300
|
+
readonly uidSearch: (criteria: UidSearchCriteria) => Promise<ImapResult<readonly number[]>>;
|
|
299
301
|
/** RFC 6851. Answers with the RFC 4315 `COPYUID`, or null when the server gave none. Refuses without MOVE. */
|
|
300
302
|
readonly move: (uidSet: string, mailbox: string) => Promise<ImapResult<CopyUid | null>>;
|
|
301
303
|
readonly create: (mailbox: string) => Promise<ImapResult<void>>;
|
|
@@ -340,4 +342,4 @@ export declare const MAX_UID_SET_LENGTH = 4096;
|
|
|
340
342
|
*/
|
|
341
343
|
export declare const uidSets: (uids: Iterable<number>, maxLength?: number) => readonly string[];
|
|
342
344
|
//#endregion
|
|
343
|
-
export type { ByteDuplex, CopyUid, ImapAddress, ImapClient, ImapClientOptions, ImapContinuation, ImapEnvelope, ImapFailure, ImapFetchItem, ImapIdle, ImapMailbox, ImapMessageAttachmentNames, ImapMessageSummary, ImapResponse, ImapResponseCode, ImapResult,
|
|
345
|
+
export type { ByteDuplex, CopyUid, ImapAddress, ImapClient, ImapClientOptions, ImapContinuation, ImapEnvelope, ImapFailure, ImapFetchItem, ImapIdle, ImapMailbox, ImapMessageAttachmentNames, ImapMessageSummary, ImapRawMessage, ImapResponse, ImapResponseCode, ImapResult, ImapSelected, ImapTagged, ImapToken, ImapUntagged, UidSearchCriteria, UidSearchCriterion };
|
package/dist/index.mjs
CHANGED
|
@@ -217,7 +217,8 @@ const buildSelectCommand = (tag, mailbox, readOnly = false) => {
|
|
|
217
217
|
const buildFetchSummariesCommand = (tag, set, { gmail, bySeq = false }) => ({ lines: [{ text: stringToBytes(`${tag} ${bySeq ? "FETCH" : "UID FETCH"} ${set} (FLAGS ENVELOPE INTERNALDATE RFC822.SIZE UID BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (REFERENCES)]${gmail ? " X-GM-THRID" : ""})\r\n`) }] });
|
|
218
218
|
const buildFetchFlagsCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (FLAGS UID)\r\n`) }] });
|
|
219
219
|
const buildFetchBodyStructureCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (UID BODYSTRUCTURE)\r\n`) }] });
|
|
220
|
-
|
|
220
|
+
/** `uidSet` is one uid or a uid set: each message comes back whole, unmarked. */
|
|
221
|
+
const buildFetchRawCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uidSet} (BODY.PEEK[])\r\n`) }] });
|
|
221
222
|
const STORE_OPS = {
|
|
222
223
|
add: "+FLAGS",
|
|
223
224
|
remove: "-FLAGS",
|
|
@@ -242,11 +243,18 @@ const MONTHS = [
|
|
|
242
243
|
"Dec"
|
|
243
244
|
];
|
|
244
245
|
const two = (value) => String(value).padStart(2, "0");
|
|
245
|
-
/**
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
246
|
+
/** `+hhmm` in whole minutes: an offset with seconds (a zone's local mean time before 1900) would break the grammar. */
|
|
247
|
+
const zoneOf = ({ offsetNanoseconds }) => {
|
|
248
|
+
const minutes = Math.trunc(offsetNanoseconds / 6e10);
|
|
249
|
+
return `${minutes < 0 ? "-" : "+"}${two(Math.floor(Math.abs(minutes) / 60))}${two(Math.abs(minutes) % 60)}`;
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* RFC 9051 date-time, in the date's own zone and always in the ISO calendar, whatever the caller's.
|
|
253
|
+
* The day is space-padded, which the grammar requires and several servers enforce.
|
|
254
|
+
*/
|
|
255
|
+
const formatImapDateTime = (zoned) => {
|
|
256
|
+
const date = zoned.withCalendar("iso8601");
|
|
257
|
+
return `"${String(date.day).padStart(2, " ")}-${MONTHS[date.month - 1]}-${date.year} ${two(date.hour)}:${two(date.minute)}:${two(date.second)} ${zoneOf(date)}"`;
|
|
250
258
|
};
|
|
251
259
|
const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
|
|
252
260
|
const mboxUtf7 = encodeModifiedUtf7(mailbox);
|
|
@@ -259,22 +267,39 @@ const buildAppendCommand = (tag, mailbox, flags, message, internalDate) => {
|
|
|
259
267
|
const buildExpungeCommand = (tag) => ({ lines: [{ text: stringToBytes(`${tag} EXPUNGE\r\n`) }] });
|
|
260
268
|
/** RFC 4315. */
|
|
261
269
|
const buildUidExpungeCommand = (tag, uidSet) => ({ lines: [{ text: stringToBytes(`${tag} UID EXPUNGE ${uidSet}\r\n`) }] });
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
* anything else is a UTF-8 literal, which every server must accept.
|
|
265
|
-
*/
|
|
266
|
-
const buildUidSearchCommand = (tag, criterion) => {
|
|
267
|
-
if (criterion.key === "UNSEEN" || criterion.key === "FLAGGED") return { lines: [{ text: stringToBytes(`${tag} UID SEARCH ${criterion.key}\r\n`) }] };
|
|
270
|
+
const searchPieceOf = (criterion) => {
|
|
271
|
+
if (criterion.key === "UNSEEN" || criterion.key === "FLAGGED") return { text: criterion.key };
|
|
268
272
|
if (criterion.key === "BEFORE" || criterion.key === "SINCE") {
|
|
269
|
-
const { year, month, day } = criterion.date;
|
|
270
|
-
|
|
271
|
-
return { lines: [{ text: stringToBytes(`${tag} UID SEARCH ${criterion.key} ${date}\r\n`) }] };
|
|
273
|
+
const { year, month, day } = criterion.date.withCalendar("iso8601");
|
|
274
|
+
return { text: `${criterion.key} ${day}-${MONTHS[month - 1]}-${String(year).padStart(4, "0")}` };
|
|
272
275
|
}
|
|
273
276
|
const key = criterion.key === "HEADER" ? `HEADER ${quoteString(criterion.header)}` : criterion.key;
|
|
274
|
-
return isSafeQuotedString(criterion.value) ? {
|
|
275
|
-
text:
|
|
277
|
+
return isSafeQuotedString(criterion.value) ? { text: `${key} ${quoteString(criterion.value)}` } : {
|
|
278
|
+
text: `${key} `,
|
|
276
279
|
literal: stringToBytes(criterion.value)
|
|
277
|
-
}
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* One `UID SEARCH <key> <key>…`: a message must meet every key (RFC 9051 §6.4.4), and no keys at all
|
|
284
|
+
* is `ALL`. A printable-ASCII value is a quoted string in the default charset; if any value is not,
|
|
285
|
+
* the command says `CHARSET UTF-8` and sends that value as a literal, which every server must accept.
|
|
286
|
+
*/
|
|
287
|
+
const buildUidSearchCommand = (tag, criteria) => {
|
|
288
|
+
const pieces = [criteria].flat().map(searchPieceOf);
|
|
289
|
+
const isUtf8 = pieces.some(({ literal }) => literal !== void 0);
|
|
290
|
+
const lines = [];
|
|
291
|
+
let text = `${tag} UID SEARCH${isUtf8 ? " CHARSET UTF-8" : ""}${pieces.length === 0 ? " ALL" : ""}`;
|
|
292
|
+
for (const piece of pieces) {
|
|
293
|
+
text += ` ${piece.text}`;
|
|
294
|
+
if (piece.literal === void 0) continue;
|
|
295
|
+
lines.push({
|
|
296
|
+
text: stringToBytes(text),
|
|
297
|
+
literal: piece.literal
|
|
298
|
+
});
|
|
299
|
+
text = "";
|
|
300
|
+
}
|
|
301
|
+
lines.push({ text: stringToBytes(`${text}\r\n`) });
|
|
302
|
+
return { lines };
|
|
278
303
|
};
|
|
279
304
|
/** RFC 6851. */
|
|
280
305
|
const buildMoveCommand = (tag, uidSet, mailbox) => ({ lines: [{ text: stringToBytes(`${tag} UID MOVE ${uidSet} ${quoteString(encodeModifiedUtf7(mailbox))}\r\n`) }] });
|
|
@@ -2115,7 +2140,7 @@ const createImapClient = (transport, options) => {
|
|
|
2115
2140
|
};
|
|
2116
2141
|
}),
|
|
2117
2142
|
fetchRaw: (uid) => enqueueCommand(async () => {
|
|
2118
|
-
const res = await executeCommand((tag) => buildFetchRawCommand(tag, uid));
|
|
2143
|
+
const res = await executeCommand((tag) => buildFetchRawCommand(tag, String(uid)));
|
|
2119
2144
|
if (!res.ok) return res;
|
|
2120
2145
|
for (const item of res.value.untagged) if (item.kind === "fetch") {
|
|
2121
2146
|
for (const fItem of item.items) if (fItem.kind === "body" && fItem.bytes !== null) return {
|
|
@@ -2131,6 +2156,22 @@ const createImapClient = (transport, options) => {
|
|
|
2131
2156
|
}
|
|
2132
2157
|
};
|
|
2133
2158
|
}),
|
|
2159
|
+
fetchRawMessages: (uidSet) => enqueueCommand(async () => {
|
|
2160
|
+
const res = await executeCommand((tag) => buildFetchRawCommand(tag, uidSet));
|
|
2161
|
+
if (!res.ok) return res;
|
|
2162
|
+
return {
|
|
2163
|
+
ok: true,
|
|
2164
|
+
value: res.value.untagged.flatMap((item) => {
|
|
2165
|
+
if (item.kind !== "fetch") return [];
|
|
2166
|
+
const uid = item.items.find(({ kind }) => kind === "uid");
|
|
2167
|
+
const body = item.items.find(({ kind }) => kind === "body");
|
|
2168
|
+
return uid?.kind === "uid" && body?.kind === "body" && body.bytes !== null ? [{
|
|
2169
|
+
uid: uid.uid,
|
|
2170
|
+
bytes: body.bytes
|
|
2171
|
+
}] : [];
|
|
2172
|
+
})
|
|
2173
|
+
};
|
|
2174
|
+
}),
|
|
2134
2175
|
storeFlags: (uidSet, mode, flags) => enqueueCommand(async () => {
|
|
2135
2176
|
const res = await executeCommand((tag) => buildStoreFlagsCommand(tag, uidSet, mode, flags));
|
|
2136
2177
|
if (!res.ok) return res;
|
|
@@ -2172,8 +2213,8 @@ const createImapClient = (transport, options) => {
|
|
|
2172
2213
|
value: void 0
|
|
2173
2214
|
};
|
|
2174
2215
|
}),
|
|
2175
|
-
uidSearch: (
|
|
2176
|
-
const res = await executeCommand((tag) => buildUidSearchCommand(tag,
|
|
2216
|
+
uidSearch: (criteria) => enqueueCommand(async () => {
|
|
2217
|
+
const res = await executeCommand((tag) => buildUidSearchCommand(tag, criteria));
|
|
2177
2218
|
if (!res.ok) return res;
|
|
2178
2219
|
return {
|
|
2179
2220
|
ok: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yozz.app/imap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^24.13.4",
|
|
33
|
-
"@yozz.app/smtp": "0.
|
|
34
|
-
"@yozz.app/tls": "0.
|
|
35
|
-
"@yozz.app/x509": "0.
|
|
33
|
+
"@yozz.app/smtp": "0.3.0",
|
|
34
|
+
"@yozz.app/tls": "0.3.0",
|
|
35
|
+
"@yozz.app/x509": "0.3.0",
|
|
36
36
|
"tsdown": "^0.23.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|