@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/dist/index.js
ADDED
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QueueError2,
|
|
3
|
+
invalid
|
|
4
|
+
} from "./chunks/index-p4k4rjb7.js";
|
|
5
|
+
// src/queue/envelope.ts
|
|
6
|
+
import { isMailbox } from "@bumail/smtp/client";
|
|
7
|
+
|
|
8
|
+
// src/text.ts
|
|
9
|
+
var isControl = (code) => code < 32 || code >= 127 && code <= 159;
|
|
10
|
+
function hasControl(text) {
|
|
11
|
+
for (let i = 0;i < text.length; i++) {
|
|
12
|
+
if (isControl(text.charCodeAt(i)))
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function replaceControls(text, by) {
|
|
18
|
+
let out = "";
|
|
19
|
+
for (let i = 0;i < text.length; i++) {
|
|
20
|
+
out += isControl(text.charCodeAt(i)) ? by : text[i];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function cleanText(text, max) {
|
|
25
|
+
const clean = replaceControls(text, " ").replace(/ {2,}/g, " ").trim();
|
|
26
|
+
return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean;
|
|
27
|
+
}
|
|
28
|
+
var asciiText = (text, max) => cleanText(text, max).replace(/[^ -~]/g, "?");
|
|
29
|
+
|
|
30
|
+
// src/queue/envelope.ts
|
|
31
|
+
var ADDRESS = /^[^\s<>]{1,64}@[^\s<>@]{1,253}$/u;
|
|
32
|
+
function checkAddress(name, address) {
|
|
33
|
+
if (typeof address !== "string" || address.length > 254 || hasControl(address) || !ADDRESS.test(address) || !isMailbox(address)) {
|
|
34
|
+
throw invalid(`${name} must be an address, local@domain, not ${JSON.stringify(address)}`);
|
|
35
|
+
}
|
|
36
|
+
return address;
|
|
37
|
+
}
|
|
38
|
+
var domainOf = (address) => address.slice(address.lastIndexOf("@") + 1).toLowerCase();
|
|
39
|
+
function checkEnvelope(envelope, maxRecipients) {
|
|
40
|
+
if (typeof envelope !== "object" || envelope === null) {
|
|
41
|
+
throw invalid("The envelope is an object: { from, to }");
|
|
42
|
+
}
|
|
43
|
+
const from = envelope.from === "" ? "" : checkAddress("from", envelope.from);
|
|
44
|
+
const list = typeof envelope.to === "string" ? [envelope.to] : envelope.to;
|
|
45
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
46
|
+
throw invalid("to must hold one recipient or more");
|
|
47
|
+
}
|
|
48
|
+
const seen = new Set;
|
|
49
|
+
const to = [];
|
|
50
|
+
for (const address of list) {
|
|
51
|
+
checkAddress("Each recipient", address);
|
|
52
|
+
const key = `${address.slice(0, address.lastIndexOf("@"))}@${domainOf(address)}`;
|
|
53
|
+
if (seen.has(key))
|
|
54
|
+
continue;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
to.push(address);
|
|
57
|
+
}
|
|
58
|
+
if (to.length > maxRecipients) {
|
|
59
|
+
throw new QueueError2("TOO_MANY_RECIPIENTS", `The message has ${to.length} recipients; the limit is ${maxRecipients}`);
|
|
60
|
+
}
|
|
61
|
+
return { from, to };
|
|
62
|
+
}
|
|
63
|
+
var tooBig = (max) => new QueueError2("MESSAGE_TOO_BIG", `The message is larger than the limit of ${max} bytes`);
|
|
64
|
+
var CR = 13;
|
|
65
|
+
var LF = 10;
|
|
66
|
+
function checkLineEnds(bytes) {
|
|
67
|
+
for (let i = 0;i < bytes.length; i++) {
|
|
68
|
+
const byte = bytes[i];
|
|
69
|
+
const bare = byte === CR ? bytes[i + 1] !== LF : byte === LF && (i === 0 || bytes[i - 1] !== CR);
|
|
70
|
+
if (bare) {
|
|
71
|
+
throw invalid(`The message has a bare CR or LF at byte ${i}: every line must end in CRLF`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function readMessage(source, max) {
|
|
76
|
+
if (typeof source === "string")
|
|
77
|
+
source = new TextEncoder().encode(source);
|
|
78
|
+
if (source instanceof Uint8Array) {
|
|
79
|
+
if (source.length > max)
|
|
80
|
+
throw tooBig(max);
|
|
81
|
+
return source.slice();
|
|
82
|
+
}
|
|
83
|
+
if (!(source instanceof ReadableStream)) {
|
|
84
|
+
throw invalid("The message is a Uint8Array, a string or a ReadableStream<Uint8Array>");
|
|
85
|
+
}
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
const reader = source.getReader();
|
|
89
|
+
try {
|
|
90
|
+
for (;; ) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done)
|
|
93
|
+
break;
|
|
94
|
+
if (!(value instanceof Uint8Array)) {
|
|
95
|
+
throw invalid("The message stream must give Uint8Array chunks");
|
|
96
|
+
}
|
|
97
|
+
size += value.length;
|
|
98
|
+
if (size > max)
|
|
99
|
+
throw tooBig(max);
|
|
100
|
+
chunks.push(value);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
await reader.cancel().catch(() => {});
|
|
104
|
+
throw error;
|
|
105
|
+
} finally {
|
|
106
|
+
reader.releaseLock();
|
|
107
|
+
}
|
|
108
|
+
const bytes = new Uint8Array(size);
|
|
109
|
+
let at = 0;
|
|
110
|
+
for (const chunk of chunks) {
|
|
111
|
+
bytes.set(chunk, at);
|
|
112
|
+
at += chunk.length;
|
|
113
|
+
}
|
|
114
|
+
return bytes;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/queue/events.ts
|
|
118
|
+
class Events {
|
|
119
|
+
#listeners = new Map;
|
|
120
|
+
on(event, listener) {
|
|
121
|
+
let set = this.#listeners.get(event);
|
|
122
|
+
if (!set) {
|
|
123
|
+
set = new Set;
|
|
124
|
+
this.#listeners.set(event, set);
|
|
125
|
+
}
|
|
126
|
+
set.add(listener);
|
|
127
|
+
return () => {
|
|
128
|
+
set.delete(listener);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
emit(event, payload) {
|
|
132
|
+
for (const listener of this.#listeners.get(event) ?? []) {
|
|
133
|
+
try {
|
|
134
|
+
listener(payload);
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/queue/settings/index.ts
|
|
141
|
+
import { sendMail } from "@bumail/smtp/client";
|
|
142
|
+
|
|
143
|
+
// src/queue/settings/numbers.ts
|
|
144
|
+
var MINUTE = 60000;
|
|
145
|
+
var HOUR = 60 * MINUTE;
|
|
146
|
+
var MAX_TIMER = 2147483647;
|
|
147
|
+
function numberOf(name, value, fallback, min, max = Number.MAX_SAFE_INTEGER, integer = true) {
|
|
148
|
+
if (value === undefined)
|
|
149
|
+
return fallback;
|
|
150
|
+
const ok = typeof value === "number" && (integer ? Number.isInteger(value) : Number.isFinite(value)) && value >= min && value <= max;
|
|
151
|
+
if (!ok) {
|
|
152
|
+
const kind = integer ? "an integer" : "a number";
|
|
153
|
+
const range = max === Number.MAX_SAFE_INTEGER ? `of at least ${min}` : `from ${min} to ${max}`;
|
|
154
|
+
throw invalid(`${name} must be ${kind} ${range}, not ${value}`);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/queue/settings/dsn.ts
|
|
160
|
+
function dsnOf(options, hostname) {
|
|
161
|
+
const d = options.dsn ?? {};
|
|
162
|
+
const from = checkAddress("dsn.from", d.from ?? `postmaster@${hostname}`);
|
|
163
|
+
const delayAfter = d.delayAfter === false ? false : numberOf("dsn.delayAfter", d.delayAfter, 4 * HOUR, 0);
|
|
164
|
+
const returnContent = d.returnContent ?? "headers";
|
|
165
|
+
if (returnContent !== "headers" && returnContent !== "full") {
|
|
166
|
+
throw invalid(`dsn.returnContent must be 'headers' or 'full', not ${returnContent}`);
|
|
167
|
+
}
|
|
168
|
+
return { from, delayAfter, returnContent };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/queue/settings/limits.ts
|
|
172
|
+
function retryOf(options) {
|
|
173
|
+
const r = options.retry ?? {};
|
|
174
|
+
const first = numberOf("retry.first", r.first, 30 * MINUTE, 1);
|
|
175
|
+
return {
|
|
176
|
+
first,
|
|
177
|
+
factor: numberOf("retry.factor", r.factor, 2, 1, 100, false),
|
|
178
|
+
max: numberOf("retry.max", r.max, Math.max(first, 4 * HOUR), first),
|
|
179
|
+
jitter: numberOf("retry.jitter", r.jitter, 0.1, 0, 1, false),
|
|
180
|
+
giveUpAfter: numberOf("retry.giveUpAfter", r.giveUpAfter, 120 * HOUR, 0)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function limitsOf(options) {
|
|
184
|
+
const l = options.limits ?? {};
|
|
185
|
+
return {
|
|
186
|
+
maxMessageSize: numberOf("limits.maxMessageSize", l.maxMessageSize, 25 * 1024 * 1024, 1),
|
|
187
|
+
maxRecipients: numberOf("limits.maxRecipients", l.maxRecipients, 100, 1),
|
|
188
|
+
maxItems: l.maxItems === undefined ? undefined : numberOf("limits.maxItems", l.maxItems, 0, 1),
|
|
189
|
+
maxReplyText: numberOf("limits.maxReplyText", l.maxReplyText, 512, 64, 900),
|
|
190
|
+
maxDsnReturn: numberOf("limits.maxDsnReturn", l.maxDsnReturn, 64 * 1024, 0)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/queue/settings/sessions.ts
|
|
195
|
+
var MAX_SECONDS = Math.floor(MAX_TIMER / 1000);
|
|
196
|
+
function checkSeconds(name, value) {
|
|
197
|
+
if (value === undefined)
|
|
198
|
+
return;
|
|
199
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_SECONDS) {
|
|
200
|
+
throw invalid(`${name} must be a number of seconds above 0 and at most ${MAX_SECONDS}, not ${value}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function checkTls(name, tls) {
|
|
204
|
+
if (tls !== undefined && tls !== "opportunistic" && tls !== "required" && tls !== "none") {
|
|
205
|
+
throw invalid(`${name} must be 'opportunistic', 'required' or 'none', not ${tls}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function checkSessions(options) {
|
|
209
|
+
numberOf("mxPort", options.mxPort, 25, 1, 65535);
|
|
210
|
+
checkTls("mxTls", options.mxTls);
|
|
211
|
+
const timeouts = options.timeouts;
|
|
212
|
+
if (timeouts !== undefined) {
|
|
213
|
+
if (typeof timeouts !== "object" || timeouts === null) {
|
|
214
|
+
throw invalid("timeouts must be an object of seconds");
|
|
215
|
+
}
|
|
216
|
+
for (const [step, value] of Object.entries(timeouts)) {
|
|
217
|
+
checkSeconds(`timeouts.${step}`, value);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
checkSeconds("deadline", options.deadline);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/queue/settings/routes.ts
|
|
224
|
+
function checkRoute(name, route) {
|
|
225
|
+
if (route === "mx")
|
|
226
|
+
return;
|
|
227
|
+
const smarthost = route;
|
|
228
|
+
const host = smarthost?.host;
|
|
229
|
+
if (typeof host !== "string" || !/^[^\s/]+$/.test(host)) {
|
|
230
|
+
throw invalid(`${name} must be 'mx' or a smarthost with a host`);
|
|
231
|
+
}
|
|
232
|
+
const { port, secure, tls, auth } = smarthost;
|
|
233
|
+
numberOf(`${name}.port`, port, 25, 1, 65535);
|
|
234
|
+
if (secure !== undefined && typeof secure !== "boolean") {
|
|
235
|
+
throw invalid(`${name}.secure must be true or false, not ${secure}`);
|
|
236
|
+
}
|
|
237
|
+
checkTls(`${name}.tls`, tls);
|
|
238
|
+
if (tls === "none" && secure) {
|
|
239
|
+
throw invalid(`${name}.secure is TLS from the first byte: it cannot go with tls: 'none'`);
|
|
240
|
+
}
|
|
241
|
+
if (auth === undefined)
|
|
242
|
+
return;
|
|
243
|
+
if (typeof auth?.username !== "string" || auth.username === "") {
|
|
244
|
+
throw invalid(`${name}.auth.username must be a non-empty string`);
|
|
245
|
+
}
|
|
246
|
+
if (typeof auth.password !== "string" || auth.password === "") {
|
|
247
|
+
throw invalid(`${name}.auth.password must be a non-empty string`);
|
|
248
|
+
}
|
|
249
|
+
const { mechanism } = auth;
|
|
250
|
+
if (mechanism !== undefined && mechanism !== "PLAIN" && mechanism !== "LOGIN") {
|
|
251
|
+
throw invalid(`${name}.auth.mechanism must be 'PLAIN' or 'LOGIN', not ${mechanism}`);
|
|
252
|
+
}
|
|
253
|
+
if (tls !== undefined && tls !== "required") {
|
|
254
|
+
throw invalid(`${name}.auth needs tls: 'required', the default with auth: with tls: '${tls}' the password would go to a server whose certificate is not checked`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function checkRoutes(options) {
|
|
258
|
+
checkRoute("route", options.route ?? "mx");
|
|
259
|
+
const routes = Object.entries(options.routes ?? {});
|
|
260
|
+
for (const [domain, route] of routes)
|
|
261
|
+
checkRoute(`routes["${domain}"]`, route);
|
|
262
|
+
const usesMx = (options.route ?? "mx") === "mx" || routes.some(([, r]) => r === "mx");
|
|
263
|
+
if (usesMx && typeof options.resolver?.mx !== "function") {
|
|
264
|
+
throw invalid("The 'mx' route needs a resolver, such as @bumail/dns's nodeResolver()");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/queue/settings/index.ts
|
|
269
|
+
var HOSTNAME = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
|
270
|
+
function settingsOf(options) {
|
|
271
|
+
if (typeof options !== "object" || options === null) {
|
|
272
|
+
throw invalid("createQueue() takes an options object");
|
|
273
|
+
}
|
|
274
|
+
if (typeof options.store?.claim !== "function") {
|
|
275
|
+
throw invalid("store must be a QueueStore, such as MemoryQueueStore");
|
|
276
|
+
}
|
|
277
|
+
const { hostname } = options;
|
|
278
|
+
if (typeof hostname !== "string" || !HOSTNAME.test(hostname)) {
|
|
279
|
+
throw invalid(`hostname must be this server's public host name, not ${hostname}`);
|
|
280
|
+
}
|
|
281
|
+
checkRoutes(options);
|
|
282
|
+
checkSessions(options);
|
|
283
|
+
const owner = options.owner ?? crypto.randomUUID();
|
|
284
|
+
if (typeof owner !== "string" || owner === "") {
|
|
285
|
+
throw invalid("owner must be a non-empty string");
|
|
286
|
+
}
|
|
287
|
+
const clock = options.clock;
|
|
288
|
+
return {
|
|
289
|
+
options,
|
|
290
|
+
hostname,
|
|
291
|
+
routes: new Map(Object.entries(options.routes ?? {}).map(([d, r]) => [
|
|
292
|
+
d.toLowerCase(),
|
|
293
|
+
r
|
|
294
|
+
])),
|
|
295
|
+
retry: retryOf(options),
|
|
296
|
+
limits: limitsOf(options),
|
|
297
|
+
dsn: dsnOf(options, hostname),
|
|
298
|
+
concurrency: numberOf("concurrency", options.concurrency, 20, 1),
|
|
299
|
+
perDomain: numberOf("perDomain", options.perDomain, 2, 1),
|
|
300
|
+
leaseMs: numberOf("leaseMs", options.leaseMs, 10 * MINUTE, 1000, 3 * MAX_TIMER),
|
|
301
|
+
pollInterval: numberOf("pollInterval", options.pollInterval, 5000, 1, MAX_TIMER),
|
|
302
|
+
owner,
|
|
303
|
+
now: clock ? () => clock.now() : Date.now,
|
|
304
|
+
send: options.send ?? sendMail,
|
|
305
|
+
random: options.random ?? Math.random
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/queue/deliver.ts
|
|
310
|
+
import { isMailbox as isMailbox2 } from "@bumail/smtp/client";
|
|
311
|
+
|
|
312
|
+
// src/dsn/build.ts
|
|
313
|
+
import {
|
|
314
|
+
encodeHeaderValue,
|
|
315
|
+
encodeQuotedPrintable,
|
|
316
|
+
foldHeader as foldHeader2,
|
|
317
|
+
formatDate as formatDate2
|
|
318
|
+
} from "@bumail/mime";
|
|
319
|
+
|
|
320
|
+
// src/dsn/content.ts
|
|
321
|
+
var CR2 = 13;
|
|
322
|
+
var LF2 = 10;
|
|
323
|
+
var MAX_LINE = 998;
|
|
324
|
+
var isContinuation = (byte) => byte !== undefined && byte >= 128 && byte < 192;
|
|
325
|
+
function crlf(bytes) {
|
|
326
|
+
const out = [];
|
|
327
|
+
let column = 0;
|
|
328
|
+
for (let i = 0;i < bytes.length; i++) {
|
|
329
|
+
const byte = bytes[i];
|
|
330
|
+
if (byte === CR2 || byte === LF2) {
|
|
331
|
+
out.push(CR2, LF2);
|
|
332
|
+
column = 0;
|
|
333
|
+
if (byte === CR2 && bytes[i + 1] === LF2)
|
|
334
|
+
i++;
|
|
335
|
+
} else if (column < MAX_LINE) {
|
|
336
|
+
out.push(byte);
|
|
337
|
+
column++;
|
|
338
|
+
} else if (column === MAX_LINE) {
|
|
339
|
+
if (isContinuation(byte)) {
|
|
340
|
+
while (isContinuation(out.at(-1))) {
|
|
341
|
+
out.pop();
|
|
342
|
+
}
|
|
343
|
+
if ((out.at(-1) ?? 0) >= 192)
|
|
344
|
+
out.pop();
|
|
345
|
+
}
|
|
346
|
+
column++;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return Uint8Array.from(out);
|
|
350
|
+
}
|
|
351
|
+
function headerEnd(bytes) {
|
|
352
|
+
for (let i = 0;i + 3 < bytes.length; i++) {
|
|
353
|
+
if (bytes[i] === CR2 && bytes[i + 1] === LF2 && bytes[i + 2] === CR2 && bytes[i + 3] === LF2) {
|
|
354
|
+
return i + 2;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return bytes.length;
|
|
358
|
+
}
|
|
359
|
+
function wholeLines(bytes, max) {
|
|
360
|
+
if (bytes.length <= max)
|
|
361
|
+
return bytes;
|
|
362
|
+
const cut = bytes.subarray(0, max).lastIndexOf(LF2);
|
|
363
|
+
return bytes.subarray(0, cut + 1);
|
|
364
|
+
}
|
|
365
|
+
function returned(original, mode, max) {
|
|
366
|
+
const message = crlf(original.subarray(0, max * 2 + 2));
|
|
367
|
+
const whole = original.length <= max && message.length <= max;
|
|
368
|
+
const body = whole && mode === "full" ? message : wholeLines(message.subarray(0, headerEnd(message)), max);
|
|
369
|
+
return {
|
|
370
|
+
type: whole && mode === "full" ? "message/rfc822" : "text/rfc822-headers",
|
|
371
|
+
body,
|
|
372
|
+
eightBit: body.some((byte) => byte > 127)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/dsn/fields.ts
|
|
377
|
+
import { foldHeader, formatDate } from "@bumail/mime";
|
|
378
|
+
var strip = (text) => replaceControls(text, "");
|
|
379
|
+
var MAX_ESCAPED = 900;
|
|
380
|
+
function typedAddress(address) {
|
|
381
|
+
const clean = strip(address).replace(/[\s\\]/g, "");
|
|
382
|
+
if (/^[\x21-\x7e]*$/.test(clean))
|
|
383
|
+
return `rfc822; ${clean}`;
|
|
384
|
+
let escaped = "";
|
|
385
|
+
for (const c of clean) {
|
|
386
|
+
const piece = /[\x21-\x7e]/.test(c) ? c : `\\x{${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()}}`;
|
|
387
|
+
if (escaped.length + piece.length > MAX_ESCAPED)
|
|
388
|
+
break;
|
|
389
|
+
escaped += piece;
|
|
390
|
+
}
|
|
391
|
+
return `utf-8; ${escaped}`;
|
|
392
|
+
}
|
|
393
|
+
var remoteMta = (host) => host && /^[A-Za-z0-9.:[\]-]{1,255}$/.test(host) ? `dns; ${host}` : undefined;
|
|
394
|
+
function statusOf(kind, reply) {
|
|
395
|
+
if (reply?.status)
|
|
396
|
+
return reply.status;
|
|
397
|
+
if (reply?.code !== undefined) {
|
|
398
|
+
return reply.code >= 500 ? "5.0.0" : "4.0.0";
|
|
399
|
+
}
|
|
400
|
+
return kind === "failed" ? "5.0.0" : "4.0.0";
|
|
401
|
+
}
|
|
402
|
+
function recipientFields(report, recipient) {
|
|
403
|
+
const { reply } = recipient;
|
|
404
|
+
const fields = [
|
|
405
|
+
["Final-Recipient", typedAddress(recipient.address)],
|
|
406
|
+
["Action", report.kind],
|
|
407
|
+
["Status", statusOf(report.kind, reply)]
|
|
408
|
+
];
|
|
409
|
+
const remote = remoteMta(reply?.host);
|
|
410
|
+
if (remote)
|
|
411
|
+
fields.push(["Remote-MTA", remote]);
|
|
412
|
+
if (reply) {
|
|
413
|
+
const text = asciiText(reply.text, 900);
|
|
414
|
+
fields.push(reply.code === undefined ? ["Diagnostic-Code", `X-Bumail; ${text}`] : ["Diagnostic-Code", `smtp; ${reply.code} ${text}`]);
|
|
415
|
+
}
|
|
416
|
+
fields.push(["Last-Attempt-Date", formatDate(recipient.lastAttempt)]);
|
|
417
|
+
if (report.kind === "delayed" && report.willRetryUntil) {
|
|
418
|
+
fields.push(["Will-Retry-Until", formatDate(report.willRetryUntil)]);
|
|
419
|
+
}
|
|
420
|
+
return fields.map(([name, value]) => foldHeader(name, value));
|
|
421
|
+
}
|
|
422
|
+
function deliveryStatus(report) {
|
|
423
|
+
const message = [
|
|
424
|
+
foldHeader("Reporting-MTA", `dns; ${strip(report.reportingMta)}`),
|
|
425
|
+
foldHeader("Arrival-Date", formatDate(report.arrival))
|
|
426
|
+
].join(`\r
|
|
427
|
+
`);
|
|
428
|
+
const blocks = report.recipients.map((r) => recipientFields(report, r).join(`\r
|
|
429
|
+
`));
|
|
430
|
+
return `${[message, ...blocks].join(`\r
|
|
431
|
+
\r
|
|
432
|
+
`)}\r
|
|
433
|
+
`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/dsn/build.ts
|
|
437
|
+
var encoder = new TextEncoder;
|
|
438
|
+
var strip2 = (text) => replaceControls(text, "").replace(/[\s<>]/g, "");
|
|
439
|
+
function humanText(input) {
|
|
440
|
+
const failed = input.kind === "failed";
|
|
441
|
+
const lines = failed ? [
|
|
442
|
+
`This is the mail system at ${input.reportingMta}.`,
|
|
443
|
+
"",
|
|
444
|
+
"Your message could not be delivered to the recipients below.",
|
|
445
|
+
"This is a permanent failure: the message will not be retried."
|
|
446
|
+
] : [
|
|
447
|
+
`This is the mail system at ${input.reportingMta}.`,
|
|
448
|
+
"",
|
|
449
|
+
"Your message has not been delivered yet to the recipients below.",
|
|
450
|
+
"This is a warning only: the message is still being retried",
|
|
451
|
+
...input.willRetryUntil ? [`until ${formatDate2(input.willRetryUntil)}.`] : [],
|
|
452
|
+
"You do not need to send it again."
|
|
453
|
+
];
|
|
454
|
+
lines.push("");
|
|
455
|
+
for (const r of input.recipients) {
|
|
456
|
+
const said = r.reply ? `${r.reply.code ?? ""} ${cleanText(r.reply.text, 900)}`.trim() : statusOf(input.kind);
|
|
457
|
+
const why = r.reply?.status === "4.4.7" ? `delivery time expired, still failing: ${said}` : said;
|
|
458
|
+
lines.push(`<${cleanText(r.address, 254)}>: ${why}`);
|
|
459
|
+
}
|
|
460
|
+
return `${lines.join(`\r
|
|
461
|
+
`)}\r
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
464
|
+
function headerFields(input, boundary) {
|
|
465
|
+
const subject = input.kind === "failed" ? "Undelivered Mail Returned to Sender" : "Delayed Mail (still being retried)";
|
|
466
|
+
const domain = strip2(input.reportingMta);
|
|
467
|
+
return [
|
|
468
|
+
foldHeader2("Date", formatDate2(input.date)),
|
|
469
|
+
foldHeader2("From", `Mail Delivery System <${strip2(input.from)}>`),
|
|
470
|
+
foldHeader2("To", `<${strip2(input.to)}>`),
|
|
471
|
+
foldHeader2("Subject", encodeHeaderValue(subject)),
|
|
472
|
+
foldHeader2("Message-ID", `<${crypto.randomUUID()}@${domain}>`),
|
|
473
|
+
foldHeader2("Auto-Submitted", "auto-replied"),
|
|
474
|
+
foldHeader2("MIME-Version", "1.0"),
|
|
475
|
+
foldHeader2("Content-Type", `multipart/report; report-type=delivery-status; boundary="${boundary}"`)
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
var concat = (parts) => {
|
|
479
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
480
|
+
let at = 0;
|
|
481
|
+
for (const part of parts) {
|
|
482
|
+
out.set(part, at);
|
|
483
|
+
at += part.length;
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
};
|
|
487
|
+
function boundaryFor(content) {
|
|
488
|
+
const text = new TextDecoder("latin1").decode(content);
|
|
489
|
+
for (;; ) {
|
|
490
|
+
const boundary = `=_dsn_${crypto.randomUUID()}`;
|
|
491
|
+
if (!text.includes(boundary))
|
|
492
|
+
return boundary;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function buildDsn(input) {
|
|
496
|
+
const original = returned(input.original, input.returnContent, input.maxReturn);
|
|
497
|
+
const boundary = boundaryFor(original.body);
|
|
498
|
+
const text = encodeQuotedPrintable(humanText(input));
|
|
499
|
+
const status = deliveryStatus({
|
|
500
|
+
kind: input.kind,
|
|
501
|
+
reportingMta: input.reportingMta,
|
|
502
|
+
arrival: input.arrival,
|
|
503
|
+
recipients: input.recipients,
|
|
504
|
+
...input.willRetryUntil ? { willRetryUntil: input.willRetryUntil } : {}
|
|
505
|
+
});
|
|
506
|
+
const head = [
|
|
507
|
+
...headerFields(input, boundary),
|
|
508
|
+
"",
|
|
509
|
+
"This is a MIME-formatted delivery status notification.",
|
|
510
|
+
"",
|
|
511
|
+
`--${boundary}`,
|
|
512
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
513
|
+
"Content-Transfer-Encoding: quoted-printable",
|
|
514
|
+
"",
|
|
515
|
+
text,
|
|
516
|
+
`--${boundary}`,
|
|
517
|
+
"Content-Type: message/delivery-status",
|
|
518
|
+
"",
|
|
519
|
+
status,
|
|
520
|
+
`--${boundary}`,
|
|
521
|
+
`Content-Type: ${original.type}`,
|
|
522
|
+
...original.eightBit ? ["Content-Transfer-Encoding: 8bit"] : [],
|
|
523
|
+
"",
|
|
524
|
+
""
|
|
525
|
+
].join(`\r
|
|
526
|
+
`);
|
|
527
|
+
return concat([
|
|
528
|
+
encoder.encode(head),
|
|
529
|
+
original.body,
|
|
530
|
+
encoder.encode(`\r
|
|
531
|
+
--${boundary}--\r
|
|
532
|
+
`)
|
|
533
|
+
]);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/queue/retry.ts
|
|
537
|
+
function retryDelay(attempts, retry, random) {
|
|
538
|
+
const exponent = Math.max(0, attempts - 1);
|
|
539
|
+
const delay = Math.min(retry.first * retry.factor ** exponent, retry.max);
|
|
540
|
+
return delay + delay * retry.jitter * Math.min(1, Math.max(0, random()));
|
|
541
|
+
}
|
|
542
|
+
var giveUpAt = (createdAt, retry) => createdAt + retry.giveUpAfter;
|
|
543
|
+
function nextAttemptAt(createdAt, now, attempts, retry, random) {
|
|
544
|
+
const end = giveUpAt(createdAt, retry);
|
|
545
|
+
if (now >= end)
|
|
546
|
+
return;
|
|
547
|
+
return Math.min(now + retryDelay(attempts, retry, random), end);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/queue/notify.ts
|
|
551
|
+
async function notify(ctx, item, message, kind, recipients, now) {
|
|
552
|
+
if (item.from === "" || recipients.length === 0)
|
|
553
|
+
return;
|
|
554
|
+
const { settings, store, events } = ctx;
|
|
555
|
+
try {
|
|
556
|
+
const lastAttempt = new Date(now);
|
|
557
|
+
const dsn = buildDsn({
|
|
558
|
+
kind,
|
|
559
|
+
reportingMta: settings.hostname,
|
|
560
|
+
from: settings.dsn.from,
|
|
561
|
+
to: item.from,
|
|
562
|
+
arrival: new Date(item.createdAt),
|
|
563
|
+
date: lastAttempt,
|
|
564
|
+
recipients: recipients.map((r) => ({
|
|
565
|
+
address: r.address,
|
|
566
|
+
...r.reply ? { reply: r.reply } : {},
|
|
567
|
+
lastAttempt
|
|
568
|
+
})),
|
|
569
|
+
...kind === "delayed" ? { willRetryUntil: new Date(giveUpAt(item.createdAt, settings.retry)) } : {},
|
|
570
|
+
original: message,
|
|
571
|
+
returnContent: settings.dsn.returnContent,
|
|
572
|
+
maxReturn: settings.limits.maxDsnReturn
|
|
573
|
+
});
|
|
574
|
+
const added = await store.add({
|
|
575
|
+
from: "",
|
|
576
|
+
to: [item.from],
|
|
577
|
+
message: dsn,
|
|
578
|
+
createdAt: now
|
|
579
|
+
});
|
|
580
|
+
events.emit("dsn", {
|
|
581
|
+
kind,
|
|
582
|
+
id: added.id,
|
|
583
|
+
of: item.id,
|
|
584
|
+
to: item.from,
|
|
585
|
+
recipients: recipients.map((r) => r.address)
|
|
586
|
+
});
|
|
587
|
+
} catch (error) {
|
|
588
|
+
events.emit("error", { error, id: item.id });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// src/queue/outcome.ts
|
|
593
|
+
var isSmtpError = (error) => error instanceof Error && error.name === "SmtpError" && typeof error.code === "string";
|
|
594
|
+
var isRouteError = (error) => isSmtpError(error) && error.code === "INVALID_OPTION";
|
|
595
|
+
var STATUS = /^[245]\.\d{1,3}\.\d{1,3}$/;
|
|
596
|
+
function diagnosticOf(reply, max, host) {
|
|
597
|
+
const text = typeof reply.text === "string" ? reply.text : reply.text.join(" ");
|
|
598
|
+
return {
|
|
599
|
+
code: reply.code,
|
|
600
|
+
...reply.status && STATUS.test(reply.status) ? { status: reply.status } : {},
|
|
601
|
+
text: cleanText(text, max),
|
|
602
|
+
...host ? { host: cleanText(host, 255) } : {}
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
var ERROR_STATUS = {
|
|
606
|
+
NULL_MX: "1.10",
|
|
607
|
+
DNS_FAILED: "1.2",
|
|
608
|
+
CONNECTION_FAILED: "4.1",
|
|
609
|
+
CONNECTION_LOST: "4.2",
|
|
610
|
+
TIMEOUT: "4.2",
|
|
611
|
+
TLS_UNAVAILABLE: "7.10",
|
|
612
|
+
TLS_FAILED: "7.0",
|
|
613
|
+
AUTH_UNAVAILABLE: "7.0",
|
|
614
|
+
INVALID_OPTION: "3.5"
|
|
615
|
+
};
|
|
616
|
+
function diagnosticOfError(error, temporary, max, host) {
|
|
617
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
618
|
+
const detail = isSmtpError(error) ? ERROR_STATUS[error.code] ?? "0.0" : "0.0";
|
|
619
|
+
return {
|
|
620
|
+
status: `${temporary ? 4 : 5}.${detail}`,
|
|
621
|
+
text: cleanText(text, max),
|
|
622
|
+
...host ? { host } : {}
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function outcomesOf(result, group, max) {
|
|
626
|
+
const host = result.host;
|
|
627
|
+
const updates = new Map;
|
|
628
|
+
for (const { recipient } of result.accepted) {
|
|
629
|
+
updates.set(recipient, {
|
|
630
|
+
address: recipient,
|
|
631
|
+
status: "delivered",
|
|
632
|
+
reply: diagnosticOf(result.reply, max, host)
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
for (const { recipient, reply } of result.rejected) {
|
|
636
|
+
updates.set(recipient, {
|
|
637
|
+
address: recipient,
|
|
638
|
+
status: reply.code >= 500 ? "failed" : "deferred",
|
|
639
|
+
reply: diagnosticOf(reply, max, host)
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return group.map((address) => updates.get(address) ?? {
|
|
643
|
+
address,
|
|
644
|
+
status: "deferred",
|
|
645
|
+
reply: { text: "The delivery said nothing of this recipient", host }
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
function outcomesOfError(error, group, max, host) {
|
|
649
|
+
const rejected = new Map(isSmtpError(error) ? (error.rejected ?? []).map((r) => [r.recipient, r.reply]) : []);
|
|
650
|
+
const temporary = !isSmtpError(error) || error.temporary || isRouteError(error);
|
|
651
|
+
return group.map((address) => {
|
|
652
|
+
const own = rejected.get(address);
|
|
653
|
+
if (own) {
|
|
654
|
+
return {
|
|
655
|
+
address,
|
|
656
|
+
status: own.code >= 500 ? "failed" : "deferred",
|
|
657
|
+
reply: diagnosticOf(own, max, host)
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
const reply = isSmtpError(error) && error.reply ? diagnosticOf(error.reply, max, host) : diagnosticOfError(error, temporary, max, host);
|
|
661
|
+
return { address, status: temporary ? "deferred" : "failed", reply };
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/queue/route.ts
|
|
666
|
+
function routeOf(settings, domain) {
|
|
667
|
+
return settings.routes.get(domain) ?? settings.options.route ?? "mx";
|
|
668
|
+
}
|
|
669
|
+
function hostOf(route) {
|
|
670
|
+
return route === "mx" ? undefined : route.host;
|
|
671
|
+
}
|
|
672
|
+
function sendOptionsOf(settings, domain, from, to) {
|
|
673
|
+
const { options, hostname } = settings;
|
|
674
|
+
const common = {
|
|
675
|
+
from,
|
|
676
|
+
to,
|
|
677
|
+
helo: hostname,
|
|
678
|
+
...options.timeouts ? { timeouts: options.timeouts } : {},
|
|
679
|
+
...options.deadline !== undefined ? { deadline: options.deadline } : {}
|
|
680
|
+
};
|
|
681
|
+
const route = routeOf(settings, domain);
|
|
682
|
+
if (route === "mx") {
|
|
683
|
+
const resolver = options.resolver;
|
|
684
|
+
if (!resolver)
|
|
685
|
+
throw new Error("unreachable: settings check the resolver");
|
|
686
|
+
return {
|
|
687
|
+
...common,
|
|
688
|
+
domain,
|
|
689
|
+
resolver,
|
|
690
|
+
...options.mxPort !== undefined ? { port: options.mxPort } : {},
|
|
691
|
+
...options.mxTls ? { tls: options.mxTls } : {}
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
...common,
|
|
696
|
+
host: route.host,
|
|
697
|
+
...route.port !== undefined ? { port: route.port } : {},
|
|
698
|
+
...route.secure !== undefined ? { secure: route.secure } : {},
|
|
699
|
+
...route.tls ? { tls: route.tls } : {},
|
|
700
|
+
...route.auth ? { auth: route.auth } : {},
|
|
701
|
+
...route.ca ? { ca: route.ca } : {}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/queue/settle.ts
|
|
706
|
+
function expired(update) {
|
|
707
|
+
return {
|
|
708
|
+
address: update.address,
|
|
709
|
+
status: "failed",
|
|
710
|
+
reply: { text: "", ...update.reply, status: "4.4.7" }
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
function settle(item, updates, skipped, now, settings) {
|
|
714
|
+
const attempts = item.attempts + (updates.length > 0 ? 1 : 0);
|
|
715
|
+
const next = nextAttemptAt(item.createdAt, now, attempts, settings.retry, settings.random);
|
|
716
|
+
const all = updates.map((u) => u.status === "deferred" && next === undefined ? expired(u) : u);
|
|
717
|
+
const deferred = all.filter((u) => u.status === "deferred");
|
|
718
|
+
const { delayAfter } = settings.dsn;
|
|
719
|
+
const delayDsn = !item.delayNotified && item.from !== "" && delayAfter !== false && deferred.length > 0 && now - item.createdAt >= delayAfter;
|
|
720
|
+
return {
|
|
721
|
+
result: {
|
|
722
|
+
now,
|
|
723
|
+
recipients: all,
|
|
724
|
+
nextAttemptAt: next === undefined || skipped && deferred.length === 0 ? now : next,
|
|
725
|
+
attempts,
|
|
726
|
+
delayNotified: item.delayNotified || delayDsn
|
|
727
|
+
},
|
|
728
|
+
delivered: all.filter((u) => u.status === "delivered"),
|
|
729
|
+
failed: all.filter((u) => u.status === "failed"),
|
|
730
|
+
deferred,
|
|
731
|
+
delayDsn
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/queue/deliver.ts
|
|
736
|
+
function groupsOf(item) {
|
|
737
|
+
const groups = new Map;
|
|
738
|
+
for (const r of item.recipients) {
|
|
739
|
+
if (r.status === "delivered" || r.status === "failed")
|
|
740
|
+
continue;
|
|
741
|
+
const domain = domainOf(r.address);
|
|
742
|
+
const group = groups.get(domain);
|
|
743
|
+
if (group)
|
|
744
|
+
group.push(r.address);
|
|
745
|
+
else
|
|
746
|
+
groups.set(domain, [r.address]);
|
|
747
|
+
}
|
|
748
|
+
return groups;
|
|
749
|
+
}
|
|
750
|
+
var badAddress = (address) => ({
|
|
751
|
+
address,
|
|
752
|
+
status: "failed",
|
|
753
|
+
reply: {
|
|
754
|
+
status: "5.1.3",
|
|
755
|
+
text: "The address is not one SMTP can carry"
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
var badSender = (address) => ({
|
|
759
|
+
address,
|
|
760
|
+
status: "failed",
|
|
761
|
+
reply: {
|
|
762
|
+
status: "5.1.7",
|
|
763
|
+
text: "The sender's address is not one SMTP can carry"
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
async function attemptGroup(ctx, item, message, domain, recipients) {
|
|
767
|
+
const { settings } = ctx;
|
|
768
|
+
const max = settings.limits.maxReplyText;
|
|
769
|
+
const bad = recipients.filter((address) => !isMailbox2(address));
|
|
770
|
+
const group = recipients.filter(isMailbox2);
|
|
771
|
+
if (group.length === 0)
|
|
772
|
+
return bad.map(badAddress);
|
|
773
|
+
try {
|
|
774
|
+
const options = sendOptionsOf(settings, domain, item.from, group);
|
|
775
|
+
const result = await settings.send(message, options);
|
|
776
|
+
return [...outcomesOf(result, group, max), ...bad.map(badAddress)];
|
|
777
|
+
} catch (error) {
|
|
778
|
+
if (isRouteError(error))
|
|
779
|
+
ctx.events.emit("error", { error, id: item.id });
|
|
780
|
+
const host = hostOf(routeOf(settings, domain));
|
|
781
|
+
return [
|
|
782
|
+
...outcomesOfError(error, group, max, host),
|
|
783
|
+
...bad.map(badAddress)
|
|
784
|
+
];
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function emitOutcomes(events, item, settled) {
|
|
788
|
+
const { attempts, nextAttemptAt } = settled.result;
|
|
789
|
+
const base = (u) => ({
|
|
790
|
+
id: item.id,
|
|
791
|
+
from: item.from,
|
|
792
|
+
recipient: u.address,
|
|
793
|
+
...u.reply ? { reply: u.reply } : {},
|
|
794
|
+
attempts
|
|
795
|
+
});
|
|
796
|
+
for (const u of settled.delivered)
|
|
797
|
+
events.emit("delivered", base(u));
|
|
798
|
+
for (const u of settled.deferred) {
|
|
799
|
+
events.emit("deferred", { ...base(u), nextAttemptAt });
|
|
800
|
+
}
|
|
801
|
+
for (const u of settled.failed)
|
|
802
|
+
events.emit("failed", base(u));
|
|
803
|
+
}
|
|
804
|
+
async function deliverItem(ctx, item) {
|
|
805
|
+
const { settings, store, events } = ctx;
|
|
806
|
+
const message = await store.readMessage(item.id);
|
|
807
|
+
if (!message)
|
|
808
|
+
return;
|
|
809
|
+
const groups = groupsOf(item);
|
|
810
|
+
let skipped = false;
|
|
811
|
+
const outcomes = item.from !== "" && !isMailbox2(item.from) ? [[...groups.values()].flat().map(badSender)] : await Promise.all([...groups].map(async ([domain, group]) => {
|
|
812
|
+
const release = await ctx.domains.acquire(domain);
|
|
813
|
+
try {
|
|
814
|
+
if (ctx.stopping()) {
|
|
815
|
+
skipped = true;
|
|
816
|
+
return [];
|
|
817
|
+
}
|
|
818
|
+
return await attemptGroup(ctx, item, message, domain, group);
|
|
819
|
+
} finally {
|
|
820
|
+
release();
|
|
821
|
+
}
|
|
822
|
+
}));
|
|
823
|
+
const now = settings.now();
|
|
824
|
+
const settled = settle(item, outcomes.flat(), skipped, now, settings);
|
|
825
|
+
const after = await store.complete(item.id, settings.owner, settled.result);
|
|
826
|
+
ctx.recorded();
|
|
827
|
+
if (!after && await store.get(item.id) === undefined) {
|
|
828
|
+
emitOutcomes(events, item, settled);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (!after) {
|
|
832
|
+
const error = new QueueError2("LEASE_LOST", `The lease on ${item.id} was lost before its outcome was recorded; another worker will try it again`);
|
|
833
|
+
events.emit("error", { error, id: item.id });
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
emitOutcomes(events, item, settled);
|
|
837
|
+
await notify(ctx, item, message, "failed", settled.failed, now);
|
|
838
|
+
if (settled.delayDsn) {
|
|
839
|
+
await notify(ctx, item, message, "delayed", settled.deferred, now);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/queue/limiter.ts
|
|
844
|
+
class Limiter {
|
|
845
|
+
#size;
|
|
846
|
+
#used = 0;
|
|
847
|
+
#waiting = [];
|
|
848
|
+
constructor(size) {
|
|
849
|
+
this.#size = size;
|
|
850
|
+
}
|
|
851
|
+
async acquire() {
|
|
852
|
+
if (this.#used >= this.#size) {
|
|
853
|
+
await new Promise((resolve) => this.#waiting.push(resolve));
|
|
854
|
+
} else {
|
|
855
|
+
this.#used++;
|
|
856
|
+
}
|
|
857
|
+
let released = false;
|
|
858
|
+
return () => {
|
|
859
|
+
if (released)
|
|
860
|
+
return;
|
|
861
|
+
released = true;
|
|
862
|
+
const next = this.#waiting.shift();
|
|
863
|
+
if (next)
|
|
864
|
+
next();
|
|
865
|
+
else
|
|
866
|
+
this.#used--;
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
class KeyedLimiter {
|
|
872
|
+
#size;
|
|
873
|
+
#limiters = new Map;
|
|
874
|
+
constructor(size) {
|
|
875
|
+
this.#size = size;
|
|
876
|
+
}
|
|
877
|
+
async acquire(key) {
|
|
878
|
+
let entry = this.#limiters.get(key);
|
|
879
|
+
if (!entry) {
|
|
880
|
+
entry = { limiter: new Limiter(this.#size), users: 0 };
|
|
881
|
+
this.#limiters.set(key, entry);
|
|
882
|
+
}
|
|
883
|
+
entry.users++;
|
|
884
|
+
const release = await entry.limiter.acquire();
|
|
885
|
+
const held = entry;
|
|
886
|
+
return () => {
|
|
887
|
+
release();
|
|
888
|
+
if (--held.users === 0)
|
|
889
|
+
this.#limiters.delete(key);
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/queue/worker.ts
|
|
895
|
+
class Worker {
|
|
896
|
+
#ctx;
|
|
897
|
+
#slots;
|
|
898
|
+
#domains;
|
|
899
|
+
#inFlight = new Set;
|
|
900
|
+
#stopping = false;
|
|
901
|
+
#restart = false;
|
|
902
|
+
#loop;
|
|
903
|
+
#wake;
|
|
904
|
+
#woken = false;
|
|
905
|
+
constructor(ctx) {
|
|
906
|
+
this.#ctx = ctx;
|
|
907
|
+
this.#slots = new Limiter(ctx.settings.concurrency);
|
|
908
|
+
this.#domains = new KeyedLimiter(ctx.settings.perDomain);
|
|
909
|
+
}
|
|
910
|
+
#renewing(item) {
|
|
911
|
+
const { settings, store, events } = this.#ctx;
|
|
912
|
+
let done = false;
|
|
913
|
+
const stop = () => {
|
|
914
|
+
done = true;
|
|
915
|
+
clearInterval(timer);
|
|
916
|
+
};
|
|
917
|
+
const timer = setInterval(() => {
|
|
918
|
+
const expiresAt = settings.now() + settings.leaseMs;
|
|
919
|
+
store.renew(item.id, settings.owner, expiresAt).then((held) => {
|
|
920
|
+
if (held || done)
|
|
921
|
+
return;
|
|
922
|
+
stop();
|
|
923
|
+
const error = new QueueError2("LEASE_LOST", `The lease on ${item.id} was lost while it was delivered`);
|
|
924
|
+
events.emit("error", { error, id: item.id });
|
|
925
|
+
}, (error) => {
|
|
926
|
+
if (!done)
|
|
927
|
+
events.emit("error", { error, id: item.id });
|
|
928
|
+
});
|
|
929
|
+
}, settings.leaseMs / 3);
|
|
930
|
+
timer.unref?.();
|
|
931
|
+
return stop;
|
|
932
|
+
}
|
|
933
|
+
async#deliver(item) {
|
|
934
|
+
const stopRenewing = this.#renewing(item);
|
|
935
|
+
try {
|
|
936
|
+
await deliverItem({
|
|
937
|
+
...this.#ctx,
|
|
938
|
+
domains: this.#domains,
|
|
939
|
+
stopping: () => this.#stopping,
|
|
940
|
+
recorded: stopRenewing
|
|
941
|
+
}, item);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
this.#ctx.events.emit("error", { error, id: item.id });
|
|
944
|
+
} finally {
|
|
945
|
+
stopRenewing();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
#claim() {
|
|
949
|
+
const { settings, store } = this.#ctx;
|
|
950
|
+
if (this.#stopping)
|
|
951
|
+
return Promise.resolve(undefined);
|
|
952
|
+
return store.claim({
|
|
953
|
+
owner: settings.owner,
|
|
954
|
+
now: settings.now(),
|
|
955
|
+
leaseMs: settings.leaseMs
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
async deliverDue() {
|
|
959
|
+
const mine = new Set;
|
|
960
|
+
let claimed = 0;
|
|
961
|
+
while (!this.#stopping) {
|
|
962
|
+
const release = await this.#slots.acquire();
|
|
963
|
+
let item;
|
|
964
|
+
try {
|
|
965
|
+
item = await this.#claim();
|
|
966
|
+
} catch (error) {
|
|
967
|
+
release();
|
|
968
|
+
await Promise.all(mine);
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
if (!item) {
|
|
972
|
+
release();
|
|
973
|
+
if (mine.size === 0)
|
|
974
|
+
break;
|
|
975
|
+
await Promise.race(mine);
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
claimed++;
|
|
979
|
+
const delivery = this.#deliver(item).finally(() => {
|
|
980
|
+
release();
|
|
981
|
+
mine.delete(delivery);
|
|
982
|
+
this.#inFlight.delete(delivery);
|
|
983
|
+
});
|
|
984
|
+
mine.add(delivery);
|
|
985
|
+
this.#inFlight.add(delivery);
|
|
986
|
+
}
|
|
987
|
+
await Promise.all(mine);
|
|
988
|
+
return claimed;
|
|
989
|
+
}
|
|
990
|
+
wake() {
|
|
991
|
+
if (this.#wake)
|
|
992
|
+
this.#wake();
|
|
993
|
+
else
|
|
994
|
+
this.#woken = true;
|
|
995
|
+
}
|
|
996
|
+
start() {
|
|
997
|
+
if (this.#stopping) {
|
|
998
|
+
this.#restart = true;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (this.#loop)
|
|
1002
|
+
return;
|
|
1003
|
+
this.#loop = this.#run();
|
|
1004
|
+
}
|
|
1005
|
+
async#sleep() {
|
|
1006
|
+
if (this.#woken) {
|
|
1007
|
+
this.#woken = false;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
await new Promise((resolve) => {
|
|
1011
|
+
const timer = setTimeout(resolve, this.#ctx.settings.pollInterval);
|
|
1012
|
+
this.#wake = () => {
|
|
1013
|
+
clearTimeout(timer);
|
|
1014
|
+
resolve();
|
|
1015
|
+
};
|
|
1016
|
+
});
|
|
1017
|
+
this.#wake = undefined;
|
|
1018
|
+
}
|
|
1019
|
+
async#run() {
|
|
1020
|
+
while (!this.#stopping) {
|
|
1021
|
+
this.#woken = false;
|
|
1022
|
+
try {
|
|
1023
|
+
await this.deliverDue();
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
this.#ctx.events.emit("error", { error });
|
|
1026
|
+
}
|
|
1027
|
+
if (this.#stopping)
|
|
1028
|
+
break;
|
|
1029
|
+
await this.#sleep();
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
async stop() {
|
|
1033
|
+
this.#restart = false;
|
|
1034
|
+
this.#stopping = true;
|
|
1035
|
+
this.#wake?.();
|
|
1036
|
+
await this.#loop;
|
|
1037
|
+
await Promise.all(this.#inFlight);
|
|
1038
|
+
this.#loop = undefined;
|
|
1039
|
+
this.#stopping = false;
|
|
1040
|
+
if (this.#restart) {
|
|
1041
|
+
this.#restart = false;
|
|
1042
|
+
this.start();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// src/queue/queue.ts
|
|
1048
|
+
function createQueue(options) {
|
|
1049
|
+
const settings = settingsOf(options);
|
|
1050
|
+
const store = options.store;
|
|
1051
|
+
const events = new Events;
|
|
1052
|
+
const worker = new Worker({ settings, store, events });
|
|
1053
|
+
const { limits } = settings;
|
|
1054
|
+
return {
|
|
1055
|
+
owner: settings.owner,
|
|
1056
|
+
async enqueue(message, envelope) {
|
|
1057
|
+
const { from, to } = checkEnvelope(envelope, limits.maxRecipients);
|
|
1058
|
+
const bytes = await readMessage(message, limits.maxMessageSize);
|
|
1059
|
+
checkLineEnds(bytes);
|
|
1060
|
+
const item = await store.add({ from, to, message: bytes, createdAt: settings.now() }, limits.maxItems === undefined ? {} : { maxItems: limits.maxItems });
|
|
1061
|
+
worker.wake();
|
|
1062
|
+
return item;
|
|
1063
|
+
},
|
|
1064
|
+
deliverDue: () => worker.deliverDue(),
|
|
1065
|
+
start: () => worker.start(),
|
|
1066
|
+
stop: () => worker.stop(),
|
|
1067
|
+
on: (event, listener) => events.on(event, listener),
|
|
1068
|
+
list: (listOptions) => store.list(listOptions),
|
|
1069
|
+
get: (id) => store.get(id),
|
|
1070
|
+
async retryNow(id) {
|
|
1071
|
+
const now = settings.now();
|
|
1072
|
+
const lease = (await store.get(id))?.lease;
|
|
1073
|
+
if (lease && lease.expiresAt > now)
|
|
1074
|
+
return false;
|
|
1075
|
+
const moved = await store.reschedule(id, now);
|
|
1076
|
+
if (moved)
|
|
1077
|
+
worker.wake();
|
|
1078
|
+
return moved;
|
|
1079
|
+
},
|
|
1080
|
+
cancel: (id) => store.cancel(id)
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
export {
|
|
1084
|
+
QueueError2 as QueueError,
|
|
1085
|
+
createQueue
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
//# debugId=06C900A54468AF5364756E2164756E21
|
|
1089
|
+
//# sourceMappingURL=index.js.map
|