@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 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>>; /** APPEND a whole RFC 5322 message to a mailbox, e.g. a Sent copy after SMTP accepted it. */
266
- 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). */
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(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). */
@@ -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.2",
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/tls": "0.1.2",
35
- "@yozz.app/x509": "0.1.2"
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
  }