@getstrata/core 2.2.22 → 2.2.23
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/CHANGELOG.md +4 -0
- package/dist/core/mail/mailer.d.ts +4 -0
- package/dist/entries/facades.js +279 -113
- package/dist/entries/mail/mailer.js +278 -112
- package/dist/index.js +278 -112
- package/package.json +1 -1
|
@@ -95,8 +95,8 @@ function resolveSmtpConfig() {
|
|
|
95
95
|
if (!from) {
|
|
96
96
|
throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
|
|
97
97
|
}
|
|
98
|
-
const port = Number
|
|
99
|
-
if (!Number.isInteger(port) || port <= 0) {
|
|
98
|
+
const port = Number(process.env.MAIL_PORT ?? "587");
|
|
99
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
100
100
|
throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
|
|
101
101
|
}
|
|
102
102
|
return {
|
|
@@ -104,6 +104,7 @@ function resolveSmtpConfig() {
|
|
|
104
104
|
port,
|
|
105
105
|
from,
|
|
106
106
|
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
107
|
+
...process.env.MAIL_TIMEOUT_MS !== undefined ? { timeoutMs: smtpTimeout(Number(process.env.MAIL_TIMEOUT_MS)) } : {},
|
|
107
108
|
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
108
109
|
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
109
110
|
};
|
|
@@ -113,6 +114,12 @@ function assertSafeSmtpField(value, field) {
|
|
|
113
114
|
if (/[\r\n]/u.test(value)) {
|
|
114
115
|
throw new Error(`Mail ${field} must not contain CR or LF.`);
|
|
115
116
|
}
|
|
117
|
+
for (const character of value) {
|
|
118
|
+
const code = character.charCodeAt(0);
|
|
119
|
+
if (code < 32 && code !== 9 || code === 127) {
|
|
120
|
+
throw new Error(`Mail ${field} must not contain control characters.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
116
123
|
return value;
|
|
117
124
|
}
|
|
118
125
|
function smtpEnvelopeAddress(value, field) {
|
|
@@ -133,148 +140,307 @@ function redactMailBody(body) {
|
|
|
133
140
|
function encodeBase64(value) {
|
|
134
141
|
return Buffer.from(value, "utf8").toString("base64");
|
|
135
142
|
}
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
while (remainder.includes(`\r
|
|
140
|
-
`)) {
|
|
141
|
-
const index = remainder.indexOf(`\r
|
|
142
|
-
`);
|
|
143
|
-
const line = remainder.slice(0, index);
|
|
144
|
-
remainder = remainder.slice(index + 2);
|
|
145
|
-
if (line.length >= 4 && line[3] === "-") {
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
responses.push(line);
|
|
143
|
+
function smtpTimeout(value = 1e4) {
|
|
144
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647) {
|
|
145
|
+
throw new RangeError("SMTP timeoutMs must be a positive integer within the timer range.");
|
|
149
146
|
}
|
|
150
|
-
return
|
|
147
|
+
return value;
|
|
151
148
|
}
|
|
152
|
-
async function
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
throw new Error(`Unexpected SMTP response: ${response}`);
|
|
149
|
+
async function defaultSmtpTransport(config, message) {
|
|
150
|
+
const timeoutMs = smtpTimeout(config.timeoutMs);
|
|
151
|
+
if (!config.host.trim() || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
152
|
+
throw new RangeError("SMTP requires a host and a port between 1 and 65535.");
|
|
157
153
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
154
|
+
if (Boolean(config.username) !== Boolean(config.password)) {
|
|
155
|
+
throw new Error("SMTP authentication requires both username and password.");
|
|
156
|
+
}
|
|
157
|
+
if (config.username)
|
|
158
|
+
assertSafeSmtpField(config.username, "username");
|
|
159
|
+
if (config.password)
|
|
160
|
+
assertSafeSmtpField(config.password, "password");
|
|
161
|
+
const fromHeader = assertSafeSmtpAddress(config.from, "from");
|
|
162
|
+
const toHeader = assertSafeSmtpAddress(message.to, "to");
|
|
163
|
+
assertSafeSmtpField(message.subject, "subject");
|
|
164
|
+
const fromEnvelope = smtpEnvelopeAddress(config.from, "from");
|
|
165
|
+
const toEnvelope = smtpEnvelopeAddress(message.to, "to");
|
|
166
|
+
if (/[^\x21-\x7e]/u.test(fromEnvelope + toEnvelope)) {
|
|
167
|
+
throw new Error("Default SMTP transport requires ASCII envelopes; SMTPUTF8 is not negotiated.");
|
|
168
|
+
}
|
|
169
|
+
for (const address of [fromEnvelope, toEnvelope]) {
|
|
170
|
+
const at = address.lastIndexOf("@");
|
|
171
|
+
if (Buffer.byteLength(address) > 254 || Buffer.byteLength(address.slice(0, at)) > 64) {
|
|
172
|
+
throw new RangeError("SMTP envelope address exceeds the path/local-part length limit.");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
message.signal?.throwIfAborted();
|
|
176
|
+
let socket;
|
|
177
|
+
let finished = false;
|
|
178
|
+
let failed = false;
|
|
179
|
+
let failure;
|
|
180
|
+
let rejectFailure;
|
|
181
|
+
const failurePromise = new Promise((_, reject) => {
|
|
182
|
+
rejectFailure = reject;
|
|
183
|
+
});
|
|
184
|
+
failurePromise.catch(() => {});
|
|
185
|
+
const responses = [];
|
|
162
186
|
const waiters = [];
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
let drain;
|
|
188
|
+
let drainVersion = 0;
|
|
189
|
+
let buffer = "";
|
|
190
|
+
let multilineCode;
|
|
191
|
+
let multilineLines = 0;
|
|
192
|
+
const decoder = new TextDecoder;
|
|
193
|
+
const fail = (reason) => {
|
|
194
|
+
if (finished || failed)
|
|
168
195
|
return;
|
|
196
|
+
failed = true;
|
|
197
|
+
failure = reason;
|
|
198
|
+
rejectFailure(reason);
|
|
199
|
+
waiters.length = 0;
|
|
200
|
+
drain?.();
|
|
201
|
+
drain = undefined;
|
|
202
|
+
socket?.terminate();
|
|
203
|
+
};
|
|
204
|
+
const onAbort = () => fail(message.signal?.reason ?? new DOMException("Mail send aborted.", "AbortError"));
|
|
205
|
+
const timer = setTimeout(() => fail(new DOMException("SMTP send deadline exceeded.", "TimeoutError")), timeoutMs);
|
|
206
|
+
message.signal?.addEventListener("abort", onAbort, { once: true });
|
|
207
|
+
if (message.signal?.aborted)
|
|
208
|
+
onAbort();
|
|
209
|
+
const readResponse = async (expected) => {
|
|
210
|
+
const queued = responses.shift();
|
|
211
|
+
const response = queued ?? await Promise.race([
|
|
212
|
+
failed ? Promise.reject(failure) : new Promise((resolve) => waiters.push(resolve)),
|
|
213
|
+
failurePromise
|
|
214
|
+
]);
|
|
215
|
+
const code = response.slice(0, 3);
|
|
216
|
+
if (!expected.includes(code))
|
|
217
|
+
throw new Error(`Unexpected SMTP response code: ${code}`);
|
|
218
|
+
};
|
|
219
|
+
const write = async (text) => {
|
|
220
|
+
const bytes = Buffer.from(text, "utf8");
|
|
221
|
+
let offset = 0;
|
|
222
|
+
while (offset < bytes.length) {
|
|
223
|
+
if (failed)
|
|
224
|
+
throw failure;
|
|
225
|
+
const version = drainVersion;
|
|
226
|
+
if (!socket)
|
|
227
|
+
throw new Error("SMTP socket is not connected.");
|
|
228
|
+
const written = socket.write(bytes.subarray(offset));
|
|
229
|
+
if (written < 0)
|
|
230
|
+
throw new Error("SMTP socket write failed.");
|
|
231
|
+
offset += written;
|
|
232
|
+
if (offset < bytes.length && version === drainVersion) {
|
|
233
|
+
await Promise.race([
|
|
234
|
+
new Promise((resolve) => {
|
|
235
|
+
drain = resolve;
|
|
236
|
+
}),
|
|
237
|
+
failurePromise
|
|
238
|
+
]);
|
|
239
|
+
}
|
|
169
240
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
buffer = parsed.remainder;
|
|
181
|
-
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
182
|
-
const response = parsed.responses.shift();
|
|
183
|
-
waiters.shift()?.resolve(response);
|
|
241
|
+
};
|
|
242
|
+
try {
|
|
243
|
+
const connecting = Bun.connect({
|
|
244
|
+
hostname: config.host,
|
|
245
|
+
port: config.port,
|
|
246
|
+
...config.secure ? {
|
|
247
|
+
tls: {
|
|
248
|
+
...config.tls,
|
|
249
|
+
serverName: config.tls?.serverName ?? config.host,
|
|
250
|
+
rejectUnauthorized: true
|
|
184
251
|
}
|
|
185
|
-
},
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
252
|
+
} : {},
|
|
253
|
+
socket: {
|
|
254
|
+
open(value) {
|
|
255
|
+
socket = value;
|
|
256
|
+
if (failed || finished)
|
|
257
|
+
value.terminate();
|
|
258
|
+
},
|
|
259
|
+
data(_socket, chunk) {
|
|
260
|
+
if (failed || finished)
|
|
261
|
+
return;
|
|
262
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
263
|
+
if (Buffer.byteLength(buffer, "utf8") > 16384) {
|
|
264
|
+
fail(new Error("SMTP response exceeded the buffer limit."));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
while (buffer.includes(`\r
|
|
268
|
+
`)) {
|
|
269
|
+
const index = buffer.indexOf(`\r
|
|
270
|
+
`);
|
|
271
|
+
const line = buffer.slice(0, index);
|
|
272
|
+
buffer = buffer.slice(index + 2);
|
|
273
|
+
const match = /^([2-5]\d{2})([- ]|$)/.exec(line);
|
|
274
|
+
if (!match || multilineCode !== undefined && match[1] !== multilineCode) {
|
|
275
|
+
fail(new Error("Malformed SMTP reply."));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (++multilineLines > 128) {
|
|
279
|
+
fail(new Error("SMTP reply exceeded the line limit."));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (match[2] === "-") {
|
|
283
|
+
multilineCode = match[1];
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
multilineCode = undefined;
|
|
287
|
+
multilineLines = 0;
|
|
288
|
+
const waiter = waiters.shift();
|
|
289
|
+
if (waiter)
|
|
290
|
+
waiter(line);
|
|
291
|
+
else {
|
|
292
|
+
responses.push(line);
|
|
293
|
+
if (responses.length > 32) {
|
|
294
|
+
fail(new Error("Too many buffered SMTP replies."));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
drain() {
|
|
301
|
+
drainVersion++;
|
|
302
|
+
const waiting = drain;
|
|
303
|
+
drain = undefined;
|
|
304
|
+
waiting?.();
|
|
305
|
+
},
|
|
306
|
+
end() {
|
|
307
|
+
fail(new Error("SMTP peer closed before the send completed."));
|
|
308
|
+
},
|
|
309
|
+
close() {
|
|
310
|
+
fail(new Error("SMTP connection closed before the send completed."));
|
|
311
|
+
},
|
|
312
|
+
error() {
|
|
313
|
+
fail(new Error("SMTP socket failed."));
|
|
314
|
+
},
|
|
315
|
+
connectError() {
|
|
316
|
+
fail(new Error("SMTP connection failed."));
|
|
317
|
+
},
|
|
318
|
+
handshake(_socket, success) {
|
|
319
|
+
if (!success)
|
|
320
|
+
fail(new Error("SMTP TLS verification failed."));
|
|
190
321
|
}
|
|
191
322
|
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
assertSafeSmtpField(config.password, "password");
|
|
202
|
-
}
|
|
203
|
-
const { socket, readResponse } = await openSmtpConnection(config);
|
|
204
|
-
try {
|
|
205
|
-
await waitForSmtpResponse(readResponse, ["220"]);
|
|
206
|
-
await socket.write(`EHLO ${smtpEhloHost()}\r
|
|
323
|
+
});
|
|
324
|
+
connecting.then((value) => {
|
|
325
|
+
socket = value;
|
|
326
|
+
if (failed || finished)
|
|
327
|
+
value.terminate();
|
|
328
|
+
}, () => {});
|
|
329
|
+
socket = await Promise.race([connecting, failurePromise]);
|
|
330
|
+
await readResponse(["220"]);
|
|
331
|
+
await write(`EHLO ${smtpEhloHost()}\r
|
|
207
332
|
`);
|
|
208
|
-
await
|
|
333
|
+
await readResponse(["250"]);
|
|
209
334
|
if (config.username && config.password) {
|
|
210
|
-
await
|
|
335
|
+
await write(`AUTH LOGIN\r
|
|
211
336
|
`);
|
|
212
|
-
await
|
|
213
|
-
await
|
|
337
|
+
await readResponse(["334"]);
|
|
338
|
+
await write(`${encodeBase64(config.username)}\r
|
|
214
339
|
`);
|
|
215
|
-
await
|
|
216
|
-
await
|
|
340
|
+
await readResponse(["334"]);
|
|
341
|
+
await write(`${encodeBase64(config.password)}\r
|
|
217
342
|
`);
|
|
218
|
-
await
|
|
343
|
+
await readResponse(["235"]);
|
|
219
344
|
}
|
|
220
|
-
|
|
221
|
-
const toHeader = assertSafeSmtpAddress(message.to, "to");
|
|
222
|
-
const from = smtpEnvelopeAddress(config.from, "from");
|
|
223
|
-
const to = smtpEnvelopeAddress(message.to, "to");
|
|
224
|
-
await socket.write(`MAIL FROM:<${from}>\r
|
|
345
|
+
await write(`MAIL FROM:<${fromEnvelope}>\r
|
|
225
346
|
`);
|
|
226
|
-
await
|
|
227
|
-
await
|
|
347
|
+
await readResponse(["250"]);
|
|
348
|
+
await write(`RCPT TO:<${toEnvelope}>\r
|
|
228
349
|
`);
|
|
229
|
-
await
|
|
230
|
-
await
|
|
350
|
+
await readResponse(["250", "251"]);
|
|
351
|
+
await write(`DATA\r
|
|
231
352
|
`);
|
|
232
|
-
await
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
await socket.write(payload);
|
|
239
|
-
await waitForSmtpResponse(readResponse, ["250"]);
|
|
240
|
-
await socket.write(`QUIT\r
|
|
353
|
+
await readResponse(["354"]);
|
|
354
|
+
await write(buildSmtpPayload(fromHeader, { ...message, to: toHeader }));
|
|
355
|
+
await readResponse(["250"]);
|
|
356
|
+
finished = true;
|
|
357
|
+
try {
|
|
358
|
+
socket.write(`QUIT\r
|
|
241
359
|
`);
|
|
242
|
-
|
|
360
|
+
} catch {}
|
|
243
361
|
} finally {
|
|
244
|
-
|
|
362
|
+
finished = true;
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
message.signal?.removeEventListener("abort", onAbort);
|
|
365
|
+
waiters.length = 0;
|
|
366
|
+
responses.length = 0;
|
|
367
|
+
drain = undefined;
|
|
368
|
+
socket?.end();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function encodeSmtpHeader(value) {
|
|
372
|
+
if (/^[\x20-\x7e]{0,60}$/u.test(value))
|
|
373
|
+
return value;
|
|
374
|
+
const words = [];
|
|
375
|
+
let part = "";
|
|
376
|
+
for (const character of value) {
|
|
377
|
+
if (Buffer.byteLength(part + character, "utf8") > 45) {
|
|
378
|
+
words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
|
|
379
|
+
part = "";
|
|
380
|
+
}
|
|
381
|
+
part += character;
|
|
245
382
|
}
|
|
383
|
+
if (part)
|
|
384
|
+
words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
|
|
385
|
+
return words.join(`\r
|
|
386
|
+
`);
|
|
387
|
+
}
|
|
388
|
+
function smtpAddressHeader(value) {
|
|
389
|
+
if (!value.includes("<"))
|
|
390
|
+
return value;
|
|
391
|
+
const name = value.slice(0, value.lastIndexOf("<")).trim();
|
|
392
|
+
const address = smtpEnvelopeAddress(value, "address");
|
|
393
|
+
if (!name)
|
|
394
|
+
return address;
|
|
395
|
+
const encoded = encodeSmtpHeader(name);
|
|
396
|
+
const display = encoded.startsWith("=?UTF-8?B?") ? encoded : `"${encoded.replace(/[\\"]/gu, "\\$&")}"`;
|
|
397
|
+
return `${display} <${address}>`;
|
|
398
|
+
}
|
|
399
|
+
function smtpMimePart(type, value) {
|
|
400
|
+
const normalized = value.replace(/\r\n|\r|\n/gu, `\r
|
|
401
|
+
`);
|
|
402
|
+
const base64 = /[^\t\r\n\x20-\x7e]/u.test(normalized) || normalized.split(`\r
|
|
403
|
+
`).some((line) => line.length > 998);
|
|
404
|
+
return [
|
|
405
|
+
`Content-Type: ${type}; charset=utf-8`,
|
|
406
|
+
`Content-Transfer-Encoding: ${base64 ? "base64" : "7bit"}`,
|
|
407
|
+
"",
|
|
408
|
+
base64 ? (encodeBase64(normalized).match(/.{1,76}/gu) ?? []).join(`\r
|
|
409
|
+
`) : normalized
|
|
410
|
+
];
|
|
246
411
|
}
|
|
247
412
|
function buildSmtpPayload(from, message) {
|
|
248
|
-
const safeFrom = assertSafeSmtpAddress(from, "from");
|
|
249
|
-
const safeTo = assertSafeSmtpAddress(message.to, "to");
|
|
250
|
-
const safeSubject = assertSafeSmtpField(message.subject, "subject");
|
|
251
413
|
const headers = [
|
|
252
|
-
`From: ${
|
|
253
|
-
`To: ${
|
|
254
|
-
`Subject: ${
|
|
414
|
+
`From: ${smtpAddressHeader(assertSafeSmtpAddress(from, "from"))}`,
|
|
415
|
+
`To: ${smtpAddressHeader(assertSafeSmtpAddress(message.to, "to"))}`,
|
|
416
|
+
`Subject: ${encodeSmtpHeader(assertSafeSmtpField(message.subject, "subject"))}`,
|
|
255
417
|
"MIME-Version: 1.0"
|
|
256
418
|
];
|
|
419
|
+
let lines;
|
|
257
420
|
if (message.html) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
421
|
+
let boundary;
|
|
422
|
+
do {
|
|
423
|
+
boundary = `strata-${crypto.randomUUID()}`;
|
|
424
|
+
} while (message.body.includes(boundary) || message.html.includes(boundary));
|
|
425
|
+
lines = [
|
|
426
|
+
...headers,
|
|
427
|
+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
263
428
|
"",
|
|
264
|
-
message.body,
|
|
265
429
|
`--${boundary}`,
|
|
266
|
-
"
|
|
267
|
-
|
|
268
|
-
message.html,
|
|
430
|
+
...smtpMimePart("text/plain", message.body),
|
|
431
|
+
`--${boundary}`,
|
|
432
|
+
...smtpMimePart("text/html", message.html),
|
|
269
433
|
`--${boundary}--`,
|
|
270
434
|
""
|
|
271
435
|
];
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return
|
|
277
|
-
`)
|
|
436
|
+
} else
|
|
437
|
+
lines = [...headers, ...smtpMimePart("text/plain", message.body)];
|
|
438
|
+
const content = lines.join(`\r
|
|
439
|
+
`).replace(/(^|\r\n)\./gu, "$1..");
|
|
440
|
+
return `${content}${content.endsWith(`\r
|
|
441
|
+
`) ? "" : `\r
|
|
442
|
+
`}.\r
|
|
443
|
+
`;
|
|
278
444
|
}
|
|
279
445
|
|
|
280
446
|
class LogMailDriver {
|