@c9up/goldstone 0.0.0-stage → 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/dist/GoldstoneProvider.d.ts +33 -0
  4. package/dist/GoldstoneProvider.d.ts.map +1 -0
  5. package/dist/GoldstoneProvider.js +55 -0
  6. package/dist/GoldstoneProvider.js.map +1 -0
  7. package/dist/InboxManager.d.ts +17 -0
  8. package/dist/InboxManager.d.ts.map +1 -0
  9. package/dist/InboxManager.js +39 -0
  10. package/dist/InboxManager.js.map +1 -0
  11. package/dist/Mailbox.d.ts +83 -0
  12. package/dist/Mailbox.d.ts.map +1 -0
  13. package/dist/Mailbox.js +326 -0
  14. package/dist/Mailbox.js.map +1 -0
  15. package/dist/ReceivedMessage.d.ts +152 -0
  16. package/dist/ReceivedMessage.d.ts.map +1 -0
  17. package/dist/ReceivedMessage.js +179 -0
  18. package/dist/ReceivedMessage.js.map +1 -0
  19. package/dist/augmentations.d.ts +18 -0
  20. package/dist/augmentations.d.ts.map +1 -0
  21. package/dist/augmentations.js +10 -0
  22. package/dist/augmentations.js.map +1 -0
  23. package/dist/authenticity.d.ts +81 -0
  24. package/dist/authenticity.d.ts.map +1 -0
  25. package/dist/authenticity.js +102 -0
  26. package/dist/authenticity.js.map +1 -0
  27. package/dist/config.d.ts +85 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +50 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/errors.d.ts +15 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +50 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/inbound/context.d.ts +43 -0
  36. package/dist/inbound/context.d.ts.map +1 -0
  37. package/dist/inbound/context.js +56 -0
  38. package/dist/inbound/context.js.map +1 -0
  39. package/dist/inbound/mailgun.d.ts +57 -0
  40. package/dist/inbound/mailgun.d.ts.map +1 -0
  41. package/dist/inbound/mailgun.js +132 -0
  42. package/dist/inbound/mailgun.js.map +1 -0
  43. package/dist/index.d.ts +11 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +11 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/native/generated.d.ts +106 -0
  48. package/dist/native/generated.d.ts.map +1 -0
  49. package/dist/native/generated.js +7 -0
  50. package/dist/native/generated.js.map +1 -0
  51. package/dist/native.d.ts +23 -0
  52. package/dist/native.d.ts.map +1 -0
  53. package/dist/native.js +45 -0
  54. package/dist/native.js.map +1 -0
  55. package/dist/services/main.d.ts +22 -0
  56. package/dist/services/main.d.ts.map +1 -0
  57. package/dist/services/main.js +43 -0
  58. package/dist/services/main.js.map +1 -0
  59. package/dist/vendor/nativeBinary.d.ts +67 -0
  60. package/dist/vendor/nativeBinary.d.ts.map +1 -0
  61. package/dist/vendor/nativeBinary.js +156 -0
  62. package/dist/vendor/nativeBinary.js.map +1 -0
  63. package/index.darwin-arm64.node +0 -0
  64. package/index.darwin-x64.node +0 -0
  65. package/index.linux-arm64-gnu.node +0 -0
  66. package/index.linux-x64-gnu.node +0 -0
  67. package/index.win32-x64-msvc.node +0 -0
  68. package/package.json +67 -3
  69. package/src/GoldstoneProvider.ts +75 -0
  70. package/src/InboxManager.ts +51 -0
  71. package/src/Mailbox.ts +433 -0
  72. package/src/ReceivedMessage.ts +287 -0
  73. package/src/augmentations.ts +22 -0
  74. package/src/authenticity.ts +183 -0
  75. package/src/config.ts +132 -0
  76. package/src/errors.ts +57 -0
  77. package/src/inbound/context.ts +92 -0
  78. package/src/inbound/mailgun.ts +201 -0
  79. package/src/index.ts +53 -0
  80. package/src/native/generated.ts +141 -0
  81. package/src/native.ts +62 -0
  82. package/src/services/main.ts +51 -0
  83. package/src/vendor/nativeBinary.ts +186 -0
package/src/Mailbox.ts ADDED
@@ -0,0 +1,433 @@
1
+ /**
2
+ * One IMAP mailbox: its connection, opened on first use, and what the
3
+ * application does with it.
4
+ *
5
+ * const support = inbox.mailbox('support')
6
+ * for (const uid of await support.search({ seen: false })) {
7
+ * const message = await support.fetch(uid)
8
+ * }
9
+ * const stop = support.watch(async (message, { uid }) => { … })
10
+ *
11
+ * Every message read goes through {@link readMessage}: its size is asked of
12
+ * the server first, so one over the limit is refused without being
13
+ * downloaded; it is then parsed, and its sender checked (DKIM, SPF, DMARC)
14
+ * unless `authenticate: false`.
15
+ */
16
+
17
+ import { ImapFlow, type SearchObject } from "imapflow";
18
+ import { assertSecure, type MailboxConfig } from "./config.js";
19
+ import { GoldstoneError } from "./errors.js";
20
+ import {
21
+ type Limits,
22
+ type ParseOptions,
23
+ type ReceivedMessage,
24
+ readMessage,
25
+ } from "./ReceivedMessage.js";
26
+
27
+ export type SearchCriteria = SearchObject;
28
+
29
+ export interface WatchOptions {
30
+ /** Default `INBOX`. */
31
+ folder?: string;
32
+ /**
33
+ * Deliver messages after this UID. Default: none of those already there —
34
+ * only what arrives once watching started.
35
+ */
36
+ afterUid?: number;
37
+ /**
38
+ * Told about what does not stop the watch: a lost connection (it
39
+ * reconnects), a message refused by the limits, a handler that threw, a
40
+ * mailbox whose UIDs were reset. Default: `console.error`.
41
+ */
42
+ onError?: (error: unknown) => void;
43
+ }
44
+
45
+ /** Wait before reconnect attempt `n`: 0.5 s, 1 s, 2 s… up to 30 s. */
46
+ export function reconnectDelay(attempt: number): number {
47
+ return Math.min(500 * 2 ** Math.max(attempt - 1, 0), 30_000);
48
+ }
49
+
50
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
51
+
52
+ /**
53
+ * Room for what is not a message in one response: SEARCH answers for a big
54
+ * mailbox, the framing around a literal.
55
+ */
56
+ const RESPONSE_ROOM = 64 * 1024 * 1024;
57
+
58
+ /** imapflow's codes for a response it stopped reading at the cap. */
59
+ const OVERSIZED = new Set([
60
+ "LiteralTooLarge",
61
+ "ResponseTooLarge",
62
+ "LineTooLarge",
63
+ ]);
64
+
65
+ function oversized(error: unknown): boolean {
66
+ const code: unknown =
67
+ typeof error === "object" && error !== null
68
+ ? Reflect.get(error, "code")
69
+ : undefined;
70
+ return typeof code === "string" && OVERSIZED.has(code);
71
+ }
72
+
73
+ export interface MailboxOptions {
74
+ /** Bounds on every message read. */
75
+ limits?: Limits;
76
+ /**
77
+ * Check DKIM, SPF and DMARC on every message read — the verdict lands on
78
+ * `message.authenticity`. Default `true`: it costs DNS lookups (cached),
79
+ * and a From nobody checked is the From a phisher wrote.
80
+ */
81
+ authenticate?: boolean;
82
+ /** Remote content in `message.html`; see {@link ParseOptions}. Default `"block"`. */
83
+ remoteContent?: ParseOptions["remoteContent"];
84
+ }
85
+
86
+ export class Mailbox {
87
+ readonly name: string;
88
+ readonly #config: MailboxConfig;
89
+ readonly #limits: Limits | undefined;
90
+ readonly #authenticate: boolean;
91
+ readonly #remoteContent: ParseOptions["remoteContent"];
92
+ #client: Promise<ImapFlow> | undefined;
93
+ readonly #watches = new Set<() => Promise<void>>();
94
+ readonly #cutOff = new WeakMap<ImapFlow, unknown>();
95
+
96
+ constructor(
97
+ name: string,
98
+ config: MailboxConfig,
99
+ options: MailboxOptions = {},
100
+ ) {
101
+ assertSecure(name, config);
102
+ this.name = name;
103
+ this.#config = config;
104
+ this.#limits = options.limits;
105
+ this.#authenticate = options.authenticate ?? true;
106
+ this.#remoteContent = options.remoteContent;
107
+ }
108
+
109
+ /** Every folder's path, as the server names it. */
110
+ async folders(): Promise<string[]> {
111
+ const client = await this.#connection();
112
+ return (await client.list()).map((folder) => folder.path);
113
+ }
114
+
115
+ /** UIDs of the messages that match, in `folder` (default `INBOX`). */
116
+ async search(criteria: SearchCriteria, folder = "INBOX"): Promise<number[]> {
117
+ return this.#in(folder, async (client) => {
118
+ const uids = await client.search(criteria, { uid: true });
119
+ return Array.isArray(uids) ? [...uids].sort((a, b) => a - b) : [];
120
+ });
121
+ }
122
+
123
+ /**
124
+ * One message, parsed — or `undefined` when no message has that UID.
125
+ * Refused with `E_GOLDSTONE_MESSAGE_TOO_LARGE` before download when the
126
+ * server says it is over the limit.
127
+ */
128
+ async fetch(
129
+ uid: number,
130
+ folder = "INBOX",
131
+ ): Promise<ReceivedMessage | undefined> {
132
+ return this.#in(folder, (client) => this.#read(client, uid));
133
+ }
134
+
135
+ async markSeen(uids: number | number[], folder = "INBOX"): Promise<void> {
136
+ if (isEmpty(uids)) return;
137
+ await this.#in(folder, (client) =>
138
+ client.messageFlagsAdd(range(uids), ["\\Seen"], { uid: true }),
139
+ );
140
+ }
141
+
142
+ async markUnseen(uids: number | number[], folder = "INBOX"): Promise<void> {
143
+ if (isEmpty(uids)) return;
144
+ await this.#in(folder, (client) =>
145
+ client.messageFlagsRemove(range(uids), ["\\Seen"], { uid: true }),
146
+ );
147
+ }
148
+
149
+ async move(
150
+ uids: number | number[],
151
+ to: string,
152
+ folder = "INBOX",
153
+ ): Promise<void> {
154
+ if (isEmpty(uids)) return;
155
+ await this.#in(folder, (client) =>
156
+ client.messageMove(range(uids), to, { uid: true }),
157
+ );
158
+ }
159
+
160
+ async delete(uids: number | number[], folder = "INBOX"): Promise<void> {
161
+ if (isEmpty(uids)) return;
162
+ await this.#in(folder, (client) =>
163
+ client.messageDelete(range(uids), { uid: true }),
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Call `handler` for every new message, as it arrives (IDLE), on a
169
+ * connection of its own. A lost connection is reopened with a growing
170
+ * wait, and what arrived meanwhile is delivered on return: messages are
171
+ * followed by UID, so none is skipped and none delivered twice.
172
+ *
173
+ * A handler that throws does not stop the watch, and the message is not
174
+ * offered again — a message that always fails would otherwise stall the
175
+ * mailbox. Resolves the returned function once the watch has stopped.
176
+ */
177
+ watch(
178
+ handler: (message: ReceivedMessage, meta: { uid: number }) => unknown,
179
+ options: WatchOptions = {},
180
+ ): () => Promise<void> {
181
+ const folder = options.folder ?? "INBOX";
182
+ const report =
183
+ options.onError ??
184
+ ((error: unknown) =>
185
+ console.error(`[inbox] ${this.name}/${folder}:`, error));
186
+ let last = options.afterUid;
187
+ let uidValidity: bigint | undefined;
188
+ let stopped = false;
189
+ let current: ImapFlow | undefined;
190
+ let wake: (() => void) | undefined;
191
+
192
+ const deliverNew = async (client: ImapFlow): Promise<void> => {
193
+ const after = last ?? 0;
194
+ const found = await client.search(
195
+ { uid: `${after + 1}:*` },
196
+ { uid: true },
197
+ );
198
+ const uids = (Array.isArray(found) ? found : [])
199
+ .filter((uid) => uid > after)
200
+ .sort((a, b) => a - b);
201
+ for (const uid of uids) {
202
+ if (stopped) return;
203
+ try {
204
+ const message = await this.#read(client, uid);
205
+ if (message) await handler(message, { uid });
206
+ } catch (error) {
207
+ report(error);
208
+ // The connection is gone (a server pushing more than the
209
+ // limit is cut off): what is left is delivered after the
210
+ // reconnection, not skipped as if it had failed. A message
211
+ // over the limit is skipped: it would be refused again.
212
+ if (!client.usable) {
213
+ if (
214
+ error instanceof GoldstoneError &&
215
+ error.code === "E_GOLDSTONE_MESSAGE_TOO_LARGE"
216
+ ) {
217
+ last = uid;
218
+ }
219
+ return;
220
+ }
221
+ }
222
+ last = uid;
223
+ }
224
+ };
225
+
226
+ const run = async (): Promise<void> => {
227
+ let attempt = 0;
228
+ while (!stopped) {
229
+ try {
230
+ const client = await this.#open();
231
+ current = client;
232
+ const opened = await client.mailboxOpen(folder);
233
+ if (uidValidity !== undefined && opened.uidValidity !== uidValidity) {
234
+ report(
235
+ new GoldstoneError(
236
+ "E_GOLDSTONE_UIDVALIDITY_CHANGED",
237
+ `[inbox] ${this.name}/${folder} renumbered its messages; watching resumes from the newest`,
238
+ ),
239
+ );
240
+ last = undefined;
241
+ }
242
+ uidValidity = opened.uidValidity;
243
+ if (last === undefined) last = opened.uidNext - 1;
244
+ attempt = 0;
245
+ await new Promise<void>((resolve, reject) => {
246
+ // Listening before the catch-up: the server announces
247
+ // mail that arrives during its FETCHes, and an EXISTS
248
+ // nobody hears leaves the message waiting for the next.
249
+ // Every delivery runs after the previous one.
250
+ let queue = Promise.resolve();
251
+ client.on("exists", () => {
252
+ queue = queue.then(() => deliverNew(client)).catch(report);
253
+ });
254
+ client.on("close", () => resolve());
255
+ client.on("error", () => {});
256
+ wake = resolve;
257
+ if (stopped) resolve();
258
+ // A catch-up that fails reopens the connection.
259
+ queue = deliverNew(client).catch(reject);
260
+ });
261
+ } catch (error) {
262
+ if (!stopped) report(error);
263
+ }
264
+ current?.close();
265
+ current = undefined;
266
+ if (stopped) break;
267
+ attempt += 1;
268
+ await new Promise<void>((resolve) => {
269
+ const timer = setTimeout(resolve, reconnectDelay(attempt));
270
+ wake = () => {
271
+ clearTimeout(timer);
272
+ resolve();
273
+ };
274
+ });
275
+ }
276
+ };
277
+
278
+ const done = run();
279
+ const stop = async (): Promise<void> => {
280
+ stopped = true;
281
+ wake?.();
282
+ await current?.logout().catch(() => {});
283
+ await done;
284
+ this.#watches.delete(stop);
285
+ };
286
+ this.#watches.add(stop);
287
+ return stop;
288
+ }
289
+
290
+ /** Stop every watch and log out. */
291
+ async close(): Promise<void> {
292
+ await Promise.all([...this.#watches].map((stop) => stop()));
293
+ const client = this.#client;
294
+ this.#client = undefined;
295
+ if (client) {
296
+ await client.then((c) => c.logout()).catch(() => {});
297
+ }
298
+ }
299
+
300
+ async #read(
301
+ client: ImapFlow,
302
+ uid: number,
303
+ ): Promise<ReceivedMessage | undefined> {
304
+ const maxBytes = this.#limits?.maxBytes ?? DEFAULT_MAX_BYTES;
305
+ const sized = await client.fetchOne(
306
+ String(uid),
307
+ { uid: true, size: true },
308
+ { uid: true },
309
+ );
310
+ if (!sized) return undefined;
311
+ if (sized.size !== undefined && sized.size > maxBytes) {
312
+ throw new GoldstoneError(
313
+ "E_GOLDSTONE_MESSAGE_TOO_LARGE",
314
+ `[inbox] message ${uid} is ${sized.size} bytes, over the ${maxBytes} allowed — not downloaded`,
315
+ );
316
+ }
317
+ // The server's size is a claim: the connection itself refuses a
318
+ // literal over the limit, before buffering it.
319
+ let full: Awaited<ReturnType<ImapFlow["fetchOne"]>>;
320
+ try {
321
+ full = await client.fetchOne(
322
+ String(uid),
323
+ { uid: true, source: true },
324
+ { uid: true },
325
+ );
326
+ } catch (error) {
327
+ if (oversized(error) || oversized(this.#cutOff.get(client))) {
328
+ throw new GoldstoneError(
329
+ "E_GOLDSTONE_MESSAGE_TOO_LARGE",
330
+ `[inbox] message ${uid} is over the ${maxBytes} bytes allowed, whatever size the server reported — not downloaded`,
331
+ { cause: error },
332
+ );
333
+ }
334
+ throw error;
335
+ }
336
+ if (full === false || full === undefined || full.source === undefined) {
337
+ return undefined;
338
+ }
339
+ return readMessage(full.source, {
340
+ limits: this.#limits,
341
+ authenticate: this.#authenticate,
342
+ remoteContent: this.#remoteContent,
343
+ });
344
+ }
345
+
346
+ async #in<T>(
347
+ folder: string,
348
+ work: (client: ImapFlow) => Promise<T>,
349
+ ): Promise<T> {
350
+ const client = await this.#connection();
351
+ const lock = await client.getMailboxLock(folder);
352
+ try {
353
+ return await work(client);
354
+ } finally {
355
+ lock.release();
356
+ }
357
+ }
358
+
359
+ /** The shared connection, opened on first use and again after a loss. */
360
+ #connection(): Promise<ImapFlow> {
361
+ if (!this.#client) {
362
+ const opening = this.#open();
363
+ this.#client = opening;
364
+ opening.then(
365
+ (client) =>
366
+ client.on("close", () => {
367
+ if (this.#client === opening) this.#client = undefined;
368
+ }),
369
+ () => {
370
+ if (this.#client === opening) this.#client = undefined;
371
+ },
372
+ );
373
+ }
374
+ return this.#client;
375
+ }
376
+
377
+ async #open(): Promise<ImapFlow> {
378
+ const config = this.#config;
379
+ // `starttls` alone means a plaintext connection upgraded: TLS from
380
+ // the first byte is the default only without it.
381
+ const secure = config.secure ?? config.starttls !== true;
382
+ const auth =
383
+ "pass" in config.auth
384
+ ? { user: config.auth.user, pass: config.auth.pass }
385
+ : {
386
+ user: config.auth.user,
387
+ accessToken:
388
+ typeof config.auth.accessToken === "function"
389
+ ? await config.auth.accessToken()
390
+ : config.auth.accessToken,
391
+ };
392
+ const maxBytes = this.#limits?.maxBytes ?? DEFAULT_MAX_BYTES;
393
+ const client = new ImapFlow({
394
+ // Whatever the server says a message weighs, never buffer more
395
+ // than the limit for one, nor much more for any response.
396
+ maxLiteralSize: maxBytes,
397
+ maxResponseSize: Math.max(maxBytes, RESPONSE_ROOM) + 64 * 1024,
398
+ maxLineLength: Math.max(maxBytes, RESPONSE_ROOM),
399
+ host: config.host,
400
+ port: config.port ?? (secure ? 993 : 143),
401
+ secure,
402
+ // Never the silent fallback to cleartext: either TLS from the start,
403
+ // or STARTTLS that must succeed, or a plaintext test server allowed
404
+ // by name.
405
+ doSTARTTLS:
406
+ !secure && config.allowInsecure !== true
407
+ ? true
408
+ : config.starttls === true,
409
+ tls: config.tls,
410
+ auth,
411
+ logger: false,
412
+ // IDLE starts after this much quiet; the default (15 s) delays every
413
+ // new-message notification by as much.
414
+ autoIdleDelay: 1000,
415
+ });
416
+ // The error that cut the connection, which a pending fetch does not
417
+ // always reject with.
418
+ client.on("error", (error: unknown) => {
419
+ this.#cutOff.set(client, error);
420
+ });
421
+ await client.connect();
422
+ return client;
423
+ }
424
+ }
425
+
426
+ /** An empty list is nothing to do — and an empty range a protocol error. */
427
+ function isEmpty(uids: number | number[]): boolean {
428
+ return Array.isArray(uids) && uids.length === 0;
429
+ }
430
+
431
+ function range(uids: number | number[]): string {
432
+ return Array.isArray(uids) ? uids.join(",") : String(uids);
433
+ }