@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 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: ImapSearchDate;
22
+ readonly date: Temporal.PlainDate;
23
23
  } | {
24
24
  readonly key: 'SINCE';
25
- readonly date: ImapSearchDate;
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?: Date) => Promise<ImapResult<AppendUid | null>>;
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 criterion, ascending. */
298
- readonly uidSearch: (criterion: UidSearchCriterion) => Promise<ImapResult<readonly number[]>>;
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, ImapSearchDate, ImapSelected, ImapTagged, ImapToken, ImapUntagged, UidSearchCriterion };
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
- const buildFetchRawCommand = (tag, uid) => ({ lines: [{ text: stringToBytes(`${tag} UID FETCH ${uid} (BODY.PEEK[])\r\n`) }] });
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
- /** RFC 9051 date-time. The day is space-padded, which the grammar requires and several servers enforce. */
246
- const formatImapDateTime = (date) => {
247
- const offset = -date.getTimezoneOffset();
248
- const zone = `${offset < 0 ? "-" : "+"}${two(Math.floor(Math.abs(offset) / 60))}${two(Math.abs(offset) % 60)}`;
249
- return `"${String(date.getDate()).padStart(2, " ")}-${MONTHS[date.getMonth()]}-${date.getFullYear()} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())} ${zone}"`;
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
- * One `UID SEARCH <key>`. A printable-ASCII value is a quoted string in the default charset;
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
- const date = `${day}-${MONTHS[month - 1]}-${String(year).padStart(4, "0")}`;
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) ? { lines: [{ text: stringToBytes(`${tag} UID SEARCH ${key} ${quoteString(criterion.value)}\r\n`) }] } : { lines: [{
275
- text: stringToBytes(`${tag} UID SEARCH CHARSET UTF-8 ${key} `),
277
+ return isSafeQuotedString(criterion.value) ? { text: `${key} ${quoteString(criterion.value)}` } : {
278
+ text: `${key} `,
276
279
  literal: stringToBytes(criterion.value)
277
- }, { text: stringToBytes("\r\n") }] };
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: (criterion) => enqueueCommand(async () => {
2176
- const res = await executeCommand((tag) => buildUidSearchCommand(tag, criterion));
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.2.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.2.0",
34
- "@yozz.app/tls": "0.2.0",
35
- "@yozz.app/x509": "0.2.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": {