@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 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
- from `@yozz.app/tls`. It knows protocol lines, `{n}` literals, command state, and RFC 2047 header
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>>; /** 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). */
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(encodeModifiedUtf7(mailbox))} (${flags.join(" ")}) `),
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.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/x509": "0.1.1"
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
  }