@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.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/chunks/index-p4k4rjb7.js +15 -0
- package/dist/chunks/index-p4k4rjb7.js.map +10 -0
- package/dist/chunks/index-xhsw4xcz.js +119 -0
- package/dist/chunks/index-xhsw4xcz.js.map +10 -0
- package/dist/contract/checks.d.ts +27 -0
- package/dist/contract/checks.d.ts.map +1 -0
- package/dist/contract/queue-store.d.ts +60 -0
- package/dist/contract/queue-store.d.ts.map +1 -0
- package/dist/contract/types.d.ts +102 -0
- package/dist/contract/types.d.ts.map +1 -0
- package/dist/dsn/build.d.ts +35 -0
- package/dist/dsn/build.d.ts.map +1 -0
- package/dist/dsn/content.d.ts +18 -0
- package/dist/dsn/content.d.ts.map +1 -0
- package/dist/dsn/fields.d.ts +38 -0
- package/dist/dsn/fields.d.ts.map +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1089 -0
- package/dist/index.js.map +30 -0
- package/dist/memory/index.d.ts +2 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +121 -0
- package/dist/memory/index.js.map +10 -0
- package/dist/memory/store.d.ts +21 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/queue/deliver.d.ts +20 -0
- package/dist/queue/deliver.d.ts.map +1 -0
- package/dist/queue/envelope.d.ts +26 -0
- package/dist/queue/envelope.d.ts.map +1 -0
- package/dist/queue/events.d.ts +48 -0
- package/dist/queue/events.d.ts.map +1 -0
- package/dist/queue/limiter.d.ts +14 -0
- package/dist/queue/limiter.d.ts.map +1 -0
- package/dist/queue/notify.d.ts +19 -0
- package/dist/queue/notify.d.ts.map +1 -0
- package/dist/queue/options.d.ts +111 -0
- package/dist/queue/options.d.ts.map +1 -0
- package/dist/queue/outcome.d.ts +26 -0
- package/dist/queue/outcome.d.ts.map +1 -0
- package/dist/queue/queue.d.ts +43 -0
- package/dist/queue/queue.d.ts.map +1 -0
- package/dist/queue/retry.d.ts +16 -0
- package/dist/queue/retry.d.ts.map +1 -0
- package/dist/queue/route.d.ts +10 -0
- package/dist/queue/route.d.ts.map +1 -0
- package/dist/queue/settings/dsn.d.ts +8 -0
- package/dist/queue/settings/dsn.d.ts.map +1 -0
- package/dist/queue/settings/index.d.ts +25 -0
- package/dist/queue/settings/index.d.ts.map +1 -0
- package/dist/queue/settings/limits.d.ts +18 -0
- package/dist/queue/settings/limits.d.ts.map +1 -0
- package/dist/queue/settings/numbers.d.ts +7 -0
- package/dist/queue/settings/numbers.d.ts.map +1 -0
- package/dist/queue/settings/routes.d.ts +3 -0
- package/dist/queue/settings/routes.d.ts.map +1 -0
- package/dist/queue/settings/sessions.d.ts +5 -0
- package/dist/queue/settings/sessions.d.ts.map +1 -0
- package/dist/queue/settle.d.ts +21 -0
- package/dist/queue/settle.d.ts.map +1 -0
- package/dist/queue/worker.d.ts +27 -0
- package/dist/queue/worker.d.ts.map +1 -0
- package/dist/sqlite/index.d.ts +2 -0
- package/dist/sqlite/index.d.ts.map +1 -0
- package/dist/sqlite/index.js +288 -0
- package/dist/sqlite/index.js.map +13 -0
- package/dist/sqlite/open.d.ts +8 -0
- package/dist/sqlite/open.d.ts.map +1 -0
- package/dist/sqlite/options.d.ts +16 -0
- package/dist/sqlite/options.d.ts.map +1 -0
- package/dist/sqlite/rows.d.ts +28 -0
- package/dist/sqlite/rows.d.ts.map +1 -0
- package/dist/sqlite/schema.d.ts +14 -0
- package/dist/sqlite/schema.d.ts.map +1 -0
- package/dist/sqlite/store.d.ts +30 -0
- package/dist/sqlite/store.d.ts.map +1 -0
- package/dist/text.d.ts +14 -0
- package/dist/text.d.ts.map +1 -0
- package/docs/README.md +10 -0
- package/docs/guide.md +461 -0
- package/docs/roadmap.md +67 -0
- package/docs/troubleshooting.md +394 -0
- 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 |
|
package/docs/roadmap.md
ADDED
|
@@ -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).
|