@bumail/queue 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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -0
  3. package/dist/chunks/index-p4k4rjb7.js +15 -0
  4. package/dist/chunks/index-p4k4rjb7.js.map +10 -0
  5. package/dist/chunks/index-xhsw4xcz.js +119 -0
  6. package/dist/chunks/index-xhsw4xcz.js.map +10 -0
  7. package/dist/contract/checks.d.ts +27 -0
  8. package/dist/contract/checks.d.ts.map +1 -0
  9. package/dist/contract/queue-store.d.ts +60 -0
  10. package/dist/contract/queue-store.d.ts.map +1 -0
  11. package/dist/contract/types.d.ts +102 -0
  12. package/dist/contract/types.d.ts.map +1 -0
  13. package/dist/dsn/build.d.ts +35 -0
  14. package/dist/dsn/build.d.ts.map +1 -0
  15. package/dist/dsn/content.d.ts +18 -0
  16. package/dist/dsn/content.d.ts.map +1 -0
  17. package/dist/dsn/fields.d.ts +38 -0
  18. package/dist/dsn/fields.d.ts.map +1 -0
  19. package/dist/errors.d.ts +17 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/index.d.ts +8 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +1089 -0
  24. package/dist/index.js.map +30 -0
  25. package/dist/memory/index.d.ts +2 -0
  26. package/dist/memory/index.d.ts.map +1 -0
  27. package/dist/memory/index.js +121 -0
  28. package/dist/memory/index.js.map +10 -0
  29. package/dist/memory/store.d.ts +21 -0
  30. package/dist/memory/store.d.ts.map +1 -0
  31. package/dist/queue/deliver.d.ts +20 -0
  32. package/dist/queue/deliver.d.ts.map +1 -0
  33. package/dist/queue/envelope.d.ts +26 -0
  34. package/dist/queue/envelope.d.ts.map +1 -0
  35. package/dist/queue/events.d.ts +48 -0
  36. package/dist/queue/events.d.ts.map +1 -0
  37. package/dist/queue/limiter.d.ts +14 -0
  38. package/dist/queue/limiter.d.ts.map +1 -0
  39. package/dist/queue/notify.d.ts +19 -0
  40. package/dist/queue/notify.d.ts.map +1 -0
  41. package/dist/queue/options.d.ts +111 -0
  42. package/dist/queue/options.d.ts.map +1 -0
  43. package/dist/queue/outcome.d.ts +26 -0
  44. package/dist/queue/outcome.d.ts.map +1 -0
  45. package/dist/queue/queue.d.ts +43 -0
  46. package/dist/queue/queue.d.ts.map +1 -0
  47. package/dist/queue/retry.d.ts +16 -0
  48. package/dist/queue/retry.d.ts.map +1 -0
  49. package/dist/queue/route.d.ts +10 -0
  50. package/dist/queue/route.d.ts.map +1 -0
  51. package/dist/queue/settings/dsn.d.ts +8 -0
  52. package/dist/queue/settings/dsn.d.ts.map +1 -0
  53. package/dist/queue/settings/index.d.ts +25 -0
  54. package/dist/queue/settings/index.d.ts.map +1 -0
  55. package/dist/queue/settings/limits.d.ts +18 -0
  56. package/dist/queue/settings/limits.d.ts.map +1 -0
  57. package/dist/queue/settings/numbers.d.ts +7 -0
  58. package/dist/queue/settings/numbers.d.ts.map +1 -0
  59. package/dist/queue/settings/routes.d.ts +3 -0
  60. package/dist/queue/settings/routes.d.ts.map +1 -0
  61. package/dist/queue/settings/sessions.d.ts +5 -0
  62. package/dist/queue/settings/sessions.d.ts.map +1 -0
  63. package/dist/queue/settle.d.ts +21 -0
  64. package/dist/queue/settle.d.ts.map +1 -0
  65. package/dist/queue/worker.d.ts +27 -0
  66. package/dist/queue/worker.d.ts.map +1 -0
  67. package/dist/sqlite/index.d.ts +2 -0
  68. package/dist/sqlite/index.d.ts.map +1 -0
  69. package/dist/sqlite/index.js +288 -0
  70. package/dist/sqlite/index.js.map +13 -0
  71. package/dist/sqlite/open.d.ts +8 -0
  72. package/dist/sqlite/open.d.ts.map +1 -0
  73. package/dist/sqlite/options.d.ts +16 -0
  74. package/dist/sqlite/options.d.ts.map +1 -0
  75. package/dist/sqlite/rows.d.ts +28 -0
  76. package/dist/sqlite/rows.d.ts.map +1 -0
  77. package/dist/sqlite/schema.d.ts +14 -0
  78. package/dist/sqlite/schema.d.ts.map +1 -0
  79. package/dist/sqlite/store.d.ts +30 -0
  80. package/dist/sqlite/store.d.ts.map +1 -0
  81. package/dist/text.d.ts +14 -0
  82. package/dist/text.d.ts.map +1 -0
  83. package/docs/README.md +10 -0
  84. package/docs/guide.md +461 -0
  85. package/docs/roadmap.md +67 -0
  86. package/docs/troubleshooting.md +394 -0
  87. package/package.json +71 -0
package/docs/guide.md ADDED
@@ -0,0 +1,461 @@
1
+ # Guide
2
+
3
+ How to send mail out with `@bumail/queue`: what the queue keeps, how it
4
+ delivers, when it tries again, what it sends back, and how several
5
+ workers share it.
6
+
7
+ - [The smallest queue](#the-smallest-queue)
8
+ - [Enqueuing](#enqueuing)
9
+ - [Delivery and routing](#delivery-and-routing)
10
+ - [Retries](#retries)
11
+ - [Delivery status notifications](#delivery-status-notifications)
12
+ - [Several workers, leases and stopping](#several-workers-leases-and-stopping)
13
+ - [Events](#events)
14
+ - [Admin](#admin)
15
+ - [The stores](#the-stores)
16
+ - [Testing](#testing)
17
+ - [Writing a store](#writing-a-store)
18
+ - [Options](#options)
19
+
20
+ ## The smallest queue
21
+
22
+ ```ts
23
+ import { nodeResolver } from '@bumail/dns';
24
+ import { createQueue } from '@bumail/queue';
25
+ import { MemoryQueueStore } from '@bumail/queue/memory';
26
+
27
+ const queue = createQueue({
28
+ store: new MemoryQueueStore(),
29
+ hostname: 'mail.example.net',
30
+ resolver: nodeResolver(),
31
+ });
32
+ queue.start();
33
+ await queue.enqueue('From: mary@example.net\r\nTo: joe@example.com\r\nSubject: Hi\r\n\r\nHello\r\n', {
34
+ from: 'mary@example.net',
35
+ to: 'joe@example.com',
36
+ });
37
+ ```
38
+
39
+ `hostname` is your server's public name. The queue gives it in EHLO, as
40
+ the Reporting-MTA of its DSNs, and in their default From
41
+ (`postmaster@<hostname>`). Mail hosts check it against your IP's reverse
42
+ DNS, so give the name your PTR record holds.
43
+
44
+ ## Enqueuing
45
+
46
+ ```ts
47
+ const item = await queue.enqueue(message, { from: 'mary@example.net', to: ['joe@example.com'] });
48
+ ```
49
+
50
+ - **The message** is a `Uint8Array`, a string (encoded as UTF-8) or a
51
+ `ReadableStream<Uint8Array>`, read to its end but never past
52
+ `limits.maxMessageSize`. It is sent as it is: sign it with DKIM
53
+ (`@bumail/auth`'s `signDkim`) before you enqueue it, and give it CRLF
54
+ line ends: a bare CR or LF is refused (`INVALID`), since `sendMail`
55
+ would refuse it at delivery (SMTP smuggling) and fail every recipient.
56
+ - **The envelope** is `{ from, to }`. `from` is the reverse-path, or `''`
57
+ for the null sender of a bounce you write yourself. `to` is one address
58
+ or an array. Each recipient is kept once: two addresses that differ
59
+ only in the case of their domain are one.
60
+ - **What comes back** is the item: an `id`, every recipient `pending`,
61
+ `nextAttemptAt` now. `start()` wakes at once to deliver it.
62
+
63
+ Every address is checked: `local@domain`, 254 characters at most, with no
64
+ space, no control character and no angle bracket — nothing that could
65
+ break an SMTP command line or a header field — and as exactly what
66
+ `sendMail` takes (`@bumail/smtp/client`'s `isMailbox`: an RFC 5321
67
+ Mailbox with no source route, no control character (C0, DEL or C1), no
68
+ `>`, no U+2028 or U+2029, no Unicode format character (`\p{Cf}`), no
69
+ lone surrogate and no IPv4 literal octet above 255):
70
+ `a,b@example.com`, `a@b@example.com` or `a@-example` are refused at
71
+ `enqueue`, never at delivery. An address a store holds that `sendMail`
72
+ would refuse anyway (written there by other code) fails alone, as
73
+ `5.1.3`; the other recipients of its domain are still delivered. A
74
+ sender it would refuse fails every recipient at once, as `5.1.7`.
75
+
76
+ ### Never an open relay
77
+
78
+ The queue sends whatever it is given, to any domain. Who may send is your
79
+ decision, made before `enqueue`: an SMTP server enqueues a message for
80
+ another domain only when the session authenticated (`@bumail/smtp`'s
81
+ server refuses to relay otherwise), and an app enqueues only for its own
82
+ users. Never enqueue what an unauthenticated client submitted.
83
+
84
+ ## Delivery and routing
85
+
86
+ An attempt delivers every recipient not yet final, grouped by domain: one
87
+ SMTP session per domain, through `@bumail/smtp/client`'s `sendMail`. Each
88
+ recipient's outcome is recorded with the reply that decided it.
89
+
90
+ | outcome | status | what happens |
91
+ | --- | --- | --- |
92
+ | `250` to RCPT and to the final dot | `delivered` | final |
93
+ | a `5xx`, to RCPT or to anything that ends the session | `failed` | final, at once; a DSN |
94
+ | a `4xx`, a connection error, a timeout, a TLS failure | `deferred` | tried again later |
95
+ | a null MX (RFC 7505), a domain that does not exist | `failed` | final; a DSN |
96
+ | `sendMail` refuses the route's options (`INVALID_OPTION`) | `deferred`, as `4.3.5` | tried again later, and an `error` event: the configuration is yours to fix |
97
+ | a recipient a store holds that `sendMail` would refuse | `failed`, as `5.1.3` | final, alone; a DSN |
98
+ | a sender a store holds that `sendMail` would refuse | `failed`, as `5.1.7` | final, every recipient, with no session; a DSN to that sender, which itself fails as `5.1.3` |
99
+
100
+ An error is read by its `name` (`SmtpError`) and `code`, not by its
101
+ class: an app with a second copy of `@bumail/smtp` installed, or a
102
+ `send` of its own that throws errors of that shape, is classified the
103
+ same way.
104
+
105
+ ### Routes
106
+
107
+ ```ts
108
+ createQueue({
109
+ store,
110
+ hostname: 'mail.example.net',
111
+ resolver: nodeResolver(),
112
+ route: 'mx', // the default
113
+ routes: {
114
+ 'partner.example': { host: 'relay.partner.example', port: 25, tls: 'required' },
115
+ 'legacy.example': { host: '10.0.0.5', tls: 'none' },
116
+ },
117
+ });
118
+ ```
119
+
120
+ - `'mx'`: the recipient domain's own mail hosts, by its MX records, its A
121
+ or AAAA when it has none. It needs `resolver`: `@bumail/dns`'s
122
+ `nodeResolver()`, `cachedResolver()` over it, or any object with `mx`,
123
+ `a` and `aaaa` of that shape. `mxPort` (25) and `mxTls`
124
+ (`opportunistic`: STARTTLS when offered, the certificate not checked,
125
+ RFC 7435) apply to it.
126
+ - A smarthost, `{ host, port?, secure?, tls?, auth?, ca? }`: every message
127
+ of the route goes to that host. `secure` is TLS from the first byte
128
+ (port 465); `auth` is sent only over TLS whose certificate checked out,
129
+ so `createQueue` refuses `auth` with a `tls` other than `'required'`
130
+ (the default with `auth`). The port, the TLS mode and the credentials
131
+ are checked by `createQueue`, as `sendMail` would check them.
132
+ - `routes` maps a recipient domain (compared in lowercase) to its own
133
+ route, over `route`.
134
+
135
+ ### Around a blocked port 25
136
+
137
+ Many cloud and home networks block outbound port 25. Send everything
138
+ through a provider's submission port instead:
139
+
140
+ ```ts
141
+ createQueue({
142
+ store,
143
+ hostname: 'mail.example.net',
144
+ route: {
145
+ host: 'smtp.provider.example',
146
+ port: 587,
147
+ auth: { username: 'mary@example.net', password: smtpPassword }, // from your secrets
148
+ },
149
+ });
150
+ ```
151
+
152
+ No resolver is needed when no route is `'mx'`.
153
+
154
+ ### Timeouts
155
+
156
+ `timeouts` and `deadline` pass to `sendMail`: each step's timeout in
157
+ seconds (RFC 5321 §4.5.3.2's by default) and the whole session's (1800
158
+ seconds, DNS included). `createQueue` checks each one: above 0, and at
159
+ most 2147483 seconds, the longest a timer waits.
160
+
161
+ ## Retries
162
+
163
+ ```ts
164
+ createQueue({
165
+ store,
166
+ hostname,
167
+ resolver,
168
+ retry: {
169
+ first: 30 * 60_000, // the first wait, after the first attempt
170
+ factor: 2, // each wait this many times the last
171
+ max: 4 * 3_600_000, // no wait longer than this
172
+ jitter: 0.1, // plus up to a tenth of the wait, at random
173
+ giveUpAfter: 5 * 24 * 3_600_000, // since enqueue
174
+ },
175
+ });
176
+ ```
177
+
178
+ The defaults follow RFC 5321 §4.5.4.1: "the retry interval SHOULD be at
179
+ least 30 minutes", and "the give-up time generally needs to be at least
180
+ 4-5 days". The waits are 30 minutes, 1 hour, 2, 4, then every 4 hours.
181
+ The jitter only adds, so 30 minutes stays a floor, and spreads the
182
+ retries of many messages deferred at once.
183
+
184
+ The last attempt falls on the moment of giving up: a recipient still
185
+ deferred then fails, its status `4.4.7` (RFC 3463: delivery time
186
+ expired), its last reply's code and text kept as the server said them;
187
+ the DSN's text for a person says the delivery time expired.
188
+
189
+ The schedule is per message: an attempt tries every recipient not yet
190
+ final, and the item's `attempts` counts them.
191
+
192
+ ## Delivery status notifications
193
+
194
+ The queue writes DSNs (RFC 3464) as a `multipart/report`
195
+ (`report-type=delivery-status`, RFC 6522) of three parts: text for a
196
+ person, the `message/delivery-status` fields a program reads, and the
197
+ original's header fields.
198
+
199
+ ```
200
+ Reporting-MTA: dns; mail.example.net
201
+ Arrival-Date: Thu, 01 Oct 2026 12:00:00 +0000
202
+
203
+ Final-Recipient: rfc822; nobody@example.com
204
+ Action: failed
205
+ Status: 5.1.1
206
+ Remote-MTA: dns; mx.example.com
207
+ Diagnostic-Code: smtp; 550 No such user here
208
+ Last-Attempt-Date: Thu, 01 Oct 2026 12:00:03 +0000
209
+ ```
210
+
211
+ - **A failure** sends one DSN for the recipients that failed in that
212
+ attempt, at once.
213
+ - **A delay** sends one "delayed" DSN, once per message, after an attempt
214
+ that leaves recipients deferred `dsn.delayAfter` after enqueue (4 hours
215
+ by default; `false` for none). It carries `Will-Retry-Until`.
216
+ - **Never about a DSN**: a message from the null sender `<>` causes no
217
+ DSN, failed or delayed. Every DSN is enqueued from `<>`, to the
218
+ original's sender, and delivered like any other item. It bypasses
219
+ `limits.maxItems`, so a full queue still bounces.
220
+ - **What it returns**: `dsn.returnContent: 'headers'` (the default)
221
+ returns the original's header fields as `text/rfc822-headers`; `'full'`
222
+ returns the whole message as `message/rfc822` when it fits
223
+ `limits.maxDsnReturn` (64 KiB), its header fields otherwise. Either is
224
+ cut after the last whole line within that bound, and every line past
225
+ 998 bytes (RFC 5322 §2.1.1) is cut there, never inside a UTF-8
226
+ character, so the DSN itself is never refused for a long line.
227
+ - **Its From** is `Mail Delivery System <postmaster@<hostname>>`, or
228
+ `dsn.from`. It carries `Auto-Submitted: auto-replied` (RFC 3834).
229
+
230
+ Nothing from outside reaches a header field raw: a reply's text, a host
231
+ name and an address have their control characters taken out (CR and LF
232
+ included), the `message/delivery-status` part is 7-bit (a UTF-8 address
233
+ is written as `utf-8; j\x{F6}rg@…`, RFC 6533), and every field is folded.
234
+
235
+ ## Several workers, leases and stopping
236
+
237
+ Any number of queues may share one store, in one process or several:
238
+
239
+ ```ts
240
+ const store = SqliteQueueStore.open({ directory: '/var/lib/bumail/queue' });
241
+ const queue = createQueue({ store, hostname, resolver, owner: `worker-${process.pid}` });
242
+ queue.start();
243
+ ```
244
+
245
+ - **A claim** takes the next due item — the earliest `nextAttemptAt`, then
246
+ the oldest — and gives it a lease: `{ owner, expiresAt }`. No other
247
+ worker claims it while the lease holds.
248
+ - **The lease is renewed** every third of `leaseMs` (10 minutes by
249
+ default) while the item is delivered, and let go of with the outcome.
250
+ A renewal that fails (the database busy) is told on the `error` event,
251
+ and the next one tries again; only a renewal that finds the lease taken
252
+ stops them.
253
+ - **A crashed worker** loses its items when their leases expire: another
254
+ worker claims them then. A worker whose lease was taken meanwhile
255
+ records nothing, and says so on the `error` event (`LEASE_LOST`). An
256
+ item cancelled while it was delivered still has its outcomes told by
257
+ the events, with no DSN and no error.
258
+ - **`stop()`** claims nothing more, lets every session under way end and
259
+ records its outcome, and gives back what was claimed but not started —
260
+ due at once, no attempt counted — so another worker can take it.
261
+
262
+ ```ts
263
+ process.on('SIGTERM', async () => {
264
+ await queue.stop();
265
+ store.close();
266
+ });
267
+ ```
268
+
269
+ `concurrency` (20) bounds how many items one worker delivers at once,
270
+ not sessions: an item opens one session per recipient domain, in
271
+ parallel, so one worker may hold up to `concurrency` times
272
+ `limits.maxRecipients` sessions. `perDomain` (2) bounds the sessions it
273
+ opens to one recipient domain, so a slow or greylisting domain does not
274
+ take every slot. A `stop()` that leaves some domains of an item untried
275
+ keeps the back-off for the whole item when another domain was deferred,
276
+ and makes it due at once otherwise. `pollInterval` (5
277
+ seconds) is how often `start()` looks for due items; `enqueue` and
278
+ `retryNow` wake it at once.
279
+
280
+ ## Events
281
+
282
+ ```ts
283
+ const off = queue.on('deferred', (event) => {
284
+ event.id; // the item
285
+ event.from; // its sender, '' for a DSN
286
+ event.recipient;
287
+ event.reply; // { code?: 451, status?: '4.3.0', text, host? }
288
+ event.attempts;
289
+ event.nextAttemptAt;
290
+ });
291
+ off(); // stops listening
292
+ ```
293
+
294
+ | event | payload |
295
+ | --- | --- |
296
+ | `delivered` | `{ id, from, recipient, reply?, attempts }` |
297
+ | `deferred` | the same, and `nextAttemptAt` |
298
+ | `failed` | the same as `delivered` |
299
+ | `dsn` | `{ kind: 'delayed' \| 'failed', id, of, to, recipients }`: `id` is the DSN's own item, `of` the item it reports on |
300
+ | `error` | `{ error, id? }`: a store that failed (a lease renewal included), a DSN that could not be enqueued, a lease lost, a route `sendMail` refused (`INVALID_OPTION`) |
301
+
302
+ Events come once the outcome is recorded. A listener that throws is
303
+ ignored. `reply.code` is absent when no server answered: a connection
304
+ error, a timeout, the DNS.
305
+
306
+ ## Admin
307
+
308
+ ```ts
309
+ await queue.list({ offset: 0, limit: 100 }); // the next due first
310
+ await queue.get(id); // undefined once done or cancelled
311
+ await queue.retryNow(id); // due now; false when there is no such item, or it is being delivered
312
+ await queue.cancel(id); // dropped, with no DSN; the item as it stood
313
+ ```
314
+
315
+ An item is dropped from the store once every recipient is `delivered` or
316
+ `failed`: `list` shows what is still to do. Use the events to keep a
317
+ history. `retryNow` on an item being delivered (its lease still held)
318
+ returns `false` and changes nothing: that attempt's outcome sets the next
319
+ one.
320
+
321
+ ## The stores
322
+
323
+ ### `MemoryQueueStore`
324
+
325
+ ```ts
326
+ import { MemoryQueueStore } from '@bumail/queue/memory';
327
+ const store = new MemoryQueueStore();
328
+ ```
329
+
330
+ For specs and trials: the queue is lost on a restart.
331
+
332
+ ### `SqliteQueueStore`
333
+
334
+ ```ts
335
+ import { SqliteQueueStore } from '@bumail/queue/sqlite';
336
+
337
+ const store = SqliteQueueStore.open({ directory: '/var/lib/bumail/queue', busyTimeout: 5000 });
338
+ // …
339
+ store.close(); // closing twice is fine
340
+ ```
341
+
342
+ - **One file**, `queue.sqlite`, in `directory`, created if need be; a
343
+ directory the store makes is 0700, and the files are 0600. A directory
344
+ that already exists keeps its mode: it is yours, and may be shared with
345
+ a group on purpose — make it 0700 yourself if it is not.
346
+ - **Durable**: WAL, `synchronous = FULL`, and on macOS `fullfsync`: every
347
+ write is on disk before it is acknowledged.
348
+ - **Shared by several processes of one machine**: unlike
349
+ `@bumail/store`'s database, the queue's is not locked to one process. A
350
+ claim is a single `UPDATE … RETURNING`, so two processes never take the
351
+ same item — a spec runs several processes on one database and checks
352
+ each item is claimed once; a writer waits up to `busyTimeout`
353
+ milliseconds for another.
354
+ Not on a network file system: SQLite's locks do not hold there. For
355
+ several machines, a server database is on the roadmap.
356
+ - **Messages in a table of their own**, `messages`, dropped with their
357
+ item in the same transaction. A queue holds a message for days at most
358
+ and never shares one between items, so content addressing (as
359
+ `@bumail/store` does on disk) would buy nothing, and a table keeps the
360
+ add and the drop atomic with no file to sweep after a crash.
361
+ - **Migrations** run on open, in one transaction; a database written by a
362
+ newer version is refused.
363
+
364
+ ## Testing
365
+
366
+ Inject the clock and the sender, and call `deliverDue()` by hand: no
367
+ timer, no network.
368
+
369
+ ```ts
370
+ import { SmtpError } from '@bumail/smtp/client';
371
+ import { createQueue, type Sender } from '@bumail/queue';
372
+ import { MemoryQueueStore } from '@bumail/queue/memory';
373
+
374
+ let now = Date.UTC(2026, 0, 1);
375
+ let calls = 0;
376
+ const send: Sender = async (_message, options) => {
377
+ if (calls++ === 0) {
378
+ throw new SmtpError('REFUSED', 'busy', { reply: { code: 451, text: 'Try later' } });
379
+ }
380
+ return {
381
+ accepted: [options.to].flat().map((recipient) => ({ recipient, reply: { code: 250, text: 'OK' } })),
382
+ rejected: [],
383
+ reply: { code: 250, text: 'Queued' },
384
+ host: 'mx.test',
385
+ port: 25,
386
+ tls: false,
387
+ authenticated: false,
388
+ };
389
+ };
390
+ const queue = createQueue({
391
+ store: new MemoryQueueStore(),
392
+ hostname: 'mail.test',
393
+ route: { host: 'mx.test' },
394
+ clock: { now: () => now },
395
+ send,
396
+ random: () => 0, // no jitter
397
+ });
398
+ const delivered: string[] = [];
399
+ queue.on('delivered', (e) => delivered.push(e.recipient));
400
+
401
+ await queue.enqueue('Subject: hi\r\n\r\nhello\r\n', { from: 'a@test', to: 'b@test' });
402
+ await queue.deliverDue(); // deferred: 451
403
+ now += 30 * 60_000;
404
+ await queue.deliverDue(); // delivered
405
+ ```
406
+
407
+ `deliverDue()` resolves once every item due at that moment, and any DSN
408
+ it enqueued meanwhile, has been tried; it resolves with how many items it
409
+ claimed.
410
+
411
+ For an end-to-end spec, run `@bumail/smtp`'s `createSmtpServer` on
412
+ `127.0.0.1`, give the queue a `@bumail/dns` `fixtureResolver` whose MX
413
+ points at it, and `mxPort` its port.
414
+
415
+ ## Writing a store
416
+
417
+ A store implements `QueueStore`, throws `QueueError` with the contract's
418
+ codes, and keeps its promises:
419
+
420
+ - `claim` is atomic: two claimers, in any process, never get the same
421
+ item; an item whose lease expired is claimable again.
422
+ - `complete` applies the outcome, the schedule and the count, and lets go
423
+ of the lease, in one step and only for the lease's owner; a final
424
+ status is never changed; an item whose every recipient is final is
425
+ dropped with its message.
426
+ - Times are given by the caller; the store keeps no clock.
427
+ - Nothing it returns is shared with what it keeps.
428
+
429
+ The contract's specs, `describeQueueStore`, which both stores here run,
430
+ are internal for now: a store written outside this package cannot run
431
+ them yet.
432
+
433
+ ## Options
434
+
435
+ | option | type | default | effect |
436
+ | --- | --- | --- | --- |
437
+ | `store` | `QueueStore` | — | where items are kept |
438
+ | `hostname` | `string` | — | EHLO, Reporting-MTA, the DSN's From |
439
+ | `route` | `'mx' \| Smarthost` | `'mx'` | the default route |
440
+ | `routes` | `Record<string, Route>` | — | a route per recipient domain |
441
+ | `resolver` | `MxResolver` | — | needed by `'mx'` |
442
+ | `mxPort` | `number`, 1–65535 | 25 | the port of MX hosts |
443
+ | `mxTls` | `TlsMode` | `opportunistic` | TLS to MX hosts |
444
+ | `timeouts`, `deadline` | seconds, above 0, at most 2147483 | `sendMail`'s | passed to `sendMail` |
445
+ | `retry` | `RetrySchedule` | 30 min, ×2, 4 h max, 10% jitter, 5 days | see [Retries](#retries) |
446
+ | `dsn.from` | `string` | `postmaster@<hostname>` | the DSN's From |
447
+ | `dsn.delayAfter` | `number \| false` | 4 hours | the "delayed" DSN |
448
+ | `dsn.returnContent` | `'headers' \| 'full'` | `headers` | what a DSN returns |
449
+ | `limits.maxMessageSize` | `number` | 25 MiB | `MESSAGE_TOO_BIG` past it |
450
+ | `limits.maxRecipients` | `number` | 100 | `TOO_MANY_RECIPIENTS` past it |
451
+ | `limits.maxItems` | `number` | none | `QUEUE_FULL` past it |
452
+ | `limits.maxReplyText` | `number`, 64–900 | 512 | characters of a reply kept |
453
+ | `limits.maxDsnReturn` | `number` | 64 KiB | bytes of the original a DSN returns |
454
+ | `concurrency` | `number` | 20 | items at once, per worker (an item: a session per domain) |
455
+ | `perDomain` | `number` | 2 | sessions at once to one domain, per worker |
456
+ | `leaseMs` | `number`, 1000 to 6442450941 | 10 minutes | a claim's lease |
457
+ | `pollInterval` | `number`, to 2147483647 | 5000 | ms between passes of `start()` |
458
+ | `owner` | `string` | a random UUID | this worker's name on its leases |
459
+ | `clock` | `{ now(): number }` | `Date.now` | the time |
460
+ | `send` | `Sender` | `sendMail` | delivers one session |
461
+ | `random` | `() => number` | `Math.random` | the jitter |
@@ -0,0 +1,67 @@
1
+ # Roadmap
2
+
3
+ What `@bumail/queue` gives a mail server, and what is coming. This page is
4
+ a direction, not a commitment: the version something shipped in is the
5
+ only number on it.
6
+
7
+ ## Now
8
+
9
+ - **The first slice, in review** — `createQueue`: every recipient's own
10
+ state, delivery by domain through `@bumail/smtp/client` (direct MX, a
11
+ smarthost, or a route per domain), a global and a per-domain
12
+ concurrency limit, retries with exponential back-off and jitter on RFC
13
+ 5321 §4.5.4.1's schedule, a 5xx failed at once, DSNs (RFC 3464) for a
14
+ failure and a delay, never about a message from `<>`; the `QueueStore`
15
+ contract with an atomic claim and leases for several workers, its
16
+ memory store and its `bun:sqlite` store, held to one spec suite;
17
+ events, `list`, `retryNow` and `cancel`; an injectable clock and sender.
18
+
19
+ ## Next
20
+
21
+ - **A PostgreSQL store**, on `Bun.sql` — the same contract for workers on
22
+ several machines, the claim a `SELECT … FOR UPDATE SKIP LOCKED`. It
23
+ runs the same contract specs.
24
+ - **A Redis store**, on `Bun.redis` — the due items in a sorted set, the
25
+ claim and the lease in one script.
26
+ - **A MongoDB store** — the contract answered structurally, by a
27
+ collection of the shape a MongoDB driver gives, so the package peers on
28
+ no driver; the claim a `findOneAndUpdate`.
29
+ - **MTA-STS (RFC 8461) and TLS-RPT (RFC 8460)** — a recipient domain's
30
+ policy fetched, cached and enforced, `mxTls: 'required'` with the
31
+ certificate checked against the policy's MX names, and the reports.
32
+ - **DANE (RFC 7672)** — TLSA records checked for MX hosts, once a
33
+ resolver that validates DNSSEC can be given to the queue.
34
+ - **One session for several messages** — the queue hands several items
35
+ for one domain to one SMTP session, once `@bumail/smtp`'s client reuses
36
+ connections.
37
+ - **The contract's specs, exported**, so a store written outside this
38
+ package can hold itself to them.
39
+ - **A DSN that cannot be lost on a crash** — today a crash between
40
+ recording a failure and enqueuing its DSN loses that DSN; the store
41
+ will take both in one step.
42
+
43
+ ## Later
44
+
45
+ - **REQUIRETLS (RFC 8689)** — a message that asks for TLS all the way is
46
+ never sent in clear, and fails rather than downgrade.
47
+ - **DSN requests (RFC 3461)** — `NOTIFY`, `RET` and `ENVID` given at
48
+ enqueue and passed on, with a "delivered" DSN when asked for.
49
+ - **Retries per recipient** — a schedule of its own for each recipient,
50
+ rather than one per message.
51
+ - **A history** — delivered and failed items kept for a while, for an
52
+ admin to look up, rather than dropped once done.
53
+
54
+ ## Not planned
55
+
56
+ - **A runtime dependency.** The memory store needs none, the `bun:sqlite`
57
+ store uses Bun's own SQLite, and the stores to come use Bun's own
58
+ `Bun.sql` and `Bun.redis`.
59
+ - **Relaying on its own.** The queue sends what the app enqueues; who may
60
+ send is decided before, by the SMTP server's AUTH or the app. No option
61
+ will make the queue accept mail from the network.
62
+ - **DKIM signing in the queue.** A message is signed before it is
63
+ enqueued, with `@bumail/auth`, so what is kept is exactly what leaves.
64
+
65
+ ## Shipped
66
+
67
+ Nothing yet: the first slice is in review (see Now).