@getstrata/core 2.2.21 → 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 +8 -0
- package/dist/core/mail/mailer.d.ts +4 -0
- package/dist/entries/audit/exportAuditLogs.js +9 -6
- package/dist/entries/auth/scimAuthMiddleware.js +9 -6
- package/dist/entries/facades.js +279 -113
- package/dist/entries/jobs/exportAuditLogsJob.js +9 -6
- package/dist/entries/mail/mailer.js +278 -112
- package/dist/entries/queue/queueMetrics.js +9 -6
- package/dist/entries/tenant/databaseTenantContext.js +9 -6
- package/dist/entries/tenant/enableTenantRls.js +17 -17
- package/dist/entries/tenant/tenantDatabaseScope.js +9 -6
- package/dist/index.js +287 -118
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @getstrata/core changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.23
|
|
4
|
+
|
|
5
|
+
Fix native SMTP hangs with bounded whole-send deadlines, cancellation, buffered multiline replies, complete writes and verified implicit TLS. Preserve known final DATA acceptance through cleanup failures. Correct DATA transparency, CRLF, UTF-8 MIME/header encoding and long-line framing; reject incomplete AUTH and unsupported SMTPUTF8 envelopes explicitly. Generate documented SMTP timeout and TLS defaults. Real socket/TLS/protocol regressions cover outages, cancellation, accepted sends and framing; SMTP delivery before final acknowledgement remains uncertain.
|
|
6
|
+
|
|
7
|
+
## 2.2.22
|
|
8
|
+
|
|
9
|
+
Preserve caller auth, credential sources, request metadata and application async stores through cold/queued native transaction callbacks. Serialize Postgres privilege grants with full provisioning using atomic database-scoped batches, retaining delegated-admin permissions and rollback/retry safety. Add real constrained-pool JSON/HTML bearer/session-CSRF and concurrent grant/atomicity regressions; no automatic workers, auth bypass or production runtime provisioning.
|
|
10
|
+
|
|
3
11
|
## 2.2.21
|
|
4
12
|
|
|
5
13
|
Make registered queue/outbox workers discoverable in CLI help and generated startup guidance. Announce polling after boot and when it stops without claiming dependency health, printing secrets, or starting workers automatically. Preserve explicit process ownership, delivery semantics and graceful lifecycle; cover idle, boot/runtime/cleanup failures and real SIGTERM draining.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { TLSOptions } from "bun";
|
|
1
2
|
interface MailMessage {
|
|
2
3
|
to: string;
|
|
3
4
|
subject: string;
|
|
4
5
|
body: string;
|
|
5
6
|
html?: string;
|
|
7
|
+
signal?: AbortSignal;
|
|
6
8
|
}
|
|
7
9
|
interface MailDriver {
|
|
8
10
|
send(message: MailMessage): Promise<void>;
|
|
@@ -14,6 +16,8 @@ interface SmtpConfig {
|
|
|
14
16
|
password?: string;
|
|
15
17
|
from: string;
|
|
16
18
|
secure: boolean;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
tls?: Pick<TLSOptions, "ca" | "serverName">;
|
|
17
21
|
}
|
|
18
22
|
type SmtpTransport = (config: SmtpConfig, message: MailMessage) => Promise<void>;
|
|
19
23
|
declare function resolveSmtpConfig(): SmtpConfig;
|
|
@@ -221,6 +221,7 @@ var init_tenancyConfig = __esm(() => {
|
|
|
221
221
|
|
|
222
222
|
// ../../src/core/tenant/tenantDatabaseScope.ts
|
|
223
223
|
import { getDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
224
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
224
225
|
async function applyTenantContextToTransaction(transaction, tenantId) {
|
|
225
226
|
await transaction.unsafe(`SELECT set_config('app.tenant_id', $1, true)`, [String(tenantId)]);
|
|
226
227
|
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, ["false"]);
|
|
@@ -243,12 +244,12 @@ async function runWithTenantDatabase(tenant, callback) {
|
|
|
243
244
|
throw new Error("RLS tenant scope requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
244
245
|
}
|
|
245
246
|
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
246
|
-
return await begin.bind(pool)(async (transaction) => {
|
|
247
|
+
return await begin.bind(pool)(AsyncLocalStorage2.bind(async (transaction) => {
|
|
247
248
|
await applyTenantContextToTransaction(transaction, tenant.id);
|
|
248
249
|
return await runWithDatabaseConnection(transaction, async () => {
|
|
249
250
|
return await runWithTransactionScope(async () => commitOrRollbackScope(await runWithTenant(tenant, callback)));
|
|
250
251
|
});
|
|
251
|
-
});
|
|
252
|
+
}));
|
|
252
253
|
}));
|
|
253
254
|
}
|
|
254
255
|
function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
|
|
@@ -717,6 +718,7 @@ var init_repositoryConnection = __esm(() => {
|
|
|
717
718
|
});
|
|
718
719
|
|
|
719
720
|
// ../../src/core/database/transaction.ts
|
|
721
|
+
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
720
722
|
async function runInSavepoint(connection, operation) {
|
|
721
723
|
const inherited = transactionFrame.getStore();
|
|
722
724
|
let parent = inherited?.connection === connection ? inherited : outerFrames.get(connection);
|
|
@@ -776,9 +778,9 @@ async function runInTransaction(operation, options = {}) {
|
|
|
776
778
|
signal?.throwIfAborted();
|
|
777
779
|
if (!supportsTransactions(connection))
|
|
778
780
|
throw new Error("Reserved database connection does not support transactions.");
|
|
779
|
-
return await connection.begin(async (transaction) => {
|
|
781
|
+
return await connection.begin(AsyncLocalStorage3.bind(async (transaction) => {
|
|
780
782
|
return await runWithDatabaseConnection(transaction, () => transactionFrame.run({ connection: transaction, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await admittedOperation(createDatabaseConnection(transaction))))));
|
|
781
|
-
});
|
|
783
|
+
}));
|
|
782
784
|
} finally {
|
|
783
785
|
await reserved?.release();
|
|
784
786
|
}
|
|
@@ -800,6 +802,7 @@ var init_transaction = __esm(() => {
|
|
|
800
802
|
|
|
801
803
|
// ../../src/core/tenant/databaseTenantContext.ts
|
|
802
804
|
import { getDefaultDatabasePool as getDefaultDatabasePool3 } from "@getstrata/core/database/defaultConnection";
|
|
805
|
+
import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
803
806
|
async function applyBypassToTransaction(transaction, bypass) {
|
|
804
807
|
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, [
|
|
805
808
|
bypass ? "true" : "false"
|
|
@@ -851,10 +854,10 @@ async function runWithScopedTenantTransaction(apply, callback) {
|
|
|
851
854
|
throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
852
855
|
}
|
|
853
856
|
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
854
|
-
return await begin.bind(pool)(async (transaction) => {
|
|
857
|
+
return await begin.bind(pool)(AsyncLocalStorage4.bind(async (transaction) => {
|
|
855
858
|
await apply(transaction);
|
|
856
859
|
return await runWithDatabaseConnection(transaction, () => runWithTransactionScope(async () => commitOrRollbackScope(await callback())));
|
|
857
|
-
});
|
|
860
|
+
}));
|
|
858
861
|
}));
|
|
859
862
|
}
|
|
860
863
|
async function runWithMigrationBypass(callback) {
|
|
@@ -673,6 +673,7 @@ var init_repositoryConnection = __esm(() => {
|
|
|
673
673
|
});
|
|
674
674
|
|
|
675
675
|
// ../../src/core/database/transaction.ts
|
|
676
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
676
677
|
async function runInSavepoint(connection, operation) {
|
|
677
678
|
const inherited = transactionFrame.getStore();
|
|
678
679
|
let parent = inherited?.connection === connection ? inherited : outerFrames.get(connection);
|
|
@@ -732,9 +733,9 @@ async function runInTransaction(operation, options = {}) {
|
|
|
732
733
|
signal?.throwIfAborted();
|
|
733
734
|
if (!supportsTransactions(connection))
|
|
734
735
|
throw new Error("Reserved database connection does not support transactions.");
|
|
735
|
-
return await connection.begin(async (transaction) => {
|
|
736
|
+
return await connection.begin(AsyncLocalStorage2.bind(async (transaction) => {
|
|
736
737
|
return await runWithDatabaseConnection(transaction, () => transactionFrame.run({ connection: transaction, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await admittedOperation(createDatabaseConnection(transaction))))));
|
|
737
|
-
});
|
|
738
|
+
}));
|
|
738
739
|
} finally {
|
|
739
740
|
await reserved?.release();
|
|
740
741
|
}
|
|
@@ -756,6 +757,7 @@ var init_transaction = __esm(() => {
|
|
|
756
757
|
|
|
757
758
|
// ../../src/core/tenant/databaseTenantContext.ts
|
|
758
759
|
import { getDefaultDatabasePool as getDefaultDatabasePool2 } from "@getstrata/core/database/defaultConnection";
|
|
760
|
+
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
759
761
|
async function applyBypassToTransaction(transaction, bypass) {
|
|
760
762
|
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, [
|
|
761
763
|
bypass ? "true" : "false"
|
|
@@ -807,10 +809,10 @@ async function runWithScopedTenantTransaction(apply, callback) {
|
|
|
807
809
|
throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
808
810
|
}
|
|
809
811
|
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
810
|
-
return await begin.bind(pool)(async (transaction) => {
|
|
812
|
+
return await begin.bind(pool)(AsyncLocalStorage3.bind(async (transaction) => {
|
|
811
813
|
await apply(transaction);
|
|
812
814
|
return await runWithDatabaseConnection(transaction, () => runWithTransactionScope(async () => commitOrRollbackScope(await callback())));
|
|
813
|
-
});
|
|
815
|
+
}));
|
|
814
816
|
}));
|
|
815
817
|
}
|
|
816
818
|
async function runWithMigrationBypass(callback) {
|
|
@@ -830,6 +832,7 @@ var init_databaseTenantContext = __esm(() => {
|
|
|
830
832
|
|
|
831
833
|
// ../../src/core/tenant/tenantDatabaseScope.ts
|
|
832
834
|
import { getDefaultDatabasePool as getDefaultDatabasePool3 } from "@getstrata/core/database/defaultConnection";
|
|
835
|
+
import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
833
836
|
async function applyTenantContextToTransaction(transaction, tenantId) {
|
|
834
837
|
await transaction.unsafe(`SELECT set_config('app.tenant_id', $1, true)`, [String(tenantId)]);
|
|
835
838
|
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, ["false"]);
|
|
@@ -852,12 +855,12 @@ async function runWithTenantDatabase(tenant, callback) {
|
|
|
852
855
|
throw new Error("RLS tenant scope requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
853
856
|
}
|
|
854
857
|
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
855
|
-
return await begin.bind(pool)(async (transaction) => {
|
|
858
|
+
return await begin.bind(pool)(AsyncLocalStorage4.bind(async (transaction) => {
|
|
856
859
|
await applyTenantContextToTransaction(transaction, tenant.id);
|
|
857
860
|
return await runWithDatabaseConnection(transaction, async () => {
|
|
858
861
|
return await runWithTransactionScope(async () => commitOrRollbackScope(await runWithTenant(tenant, callback)));
|
|
859
862
|
});
|
|
860
|
-
});
|
|
863
|
+
}));
|
|
861
864
|
}));
|
|
862
865
|
}
|
|
863
866
|
function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
|
package/dist/entries/facades.js
CHANGED
|
@@ -107,8 +107,8 @@ function resolveSmtpConfig() {
|
|
|
107
107
|
if (!from) {
|
|
108
108
|
throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
|
|
109
109
|
}
|
|
110
|
-
const port = Number
|
|
111
|
-
if (!Number.isInteger(port) || port <= 0) {
|
|
110
|
+
const port = Number(process.env.MAIL_PORT ?? "587");
|
|
111
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
112
112
|
throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
|
|
113
113
|
}
|
|
114
114
|
return {
|
|
@@ -116,6 +116,7 @@ function resolveSmtpConfig() {
|
|
|
116
116
|
port,
|
|
117
117
|
from,
|
|
118
118
|
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
119
|
+
...process.env.MAIL_TIMEOUT_MS !== undefined ? { timeoutMs: smtpTimeout(Number(process.env.MAIL_TIMEOUT_MS)) } : {},
|
|
119
120
|
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
120
121
|
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
121
122
|
};
|
|
@@ -125,6 +126,12 @@ function assertSafeSmtpField(value, field) {
|
|
|
125
126
|
if (/[\r\n]/u.test(value)) {
|
|
126
127
|
throw new Error(`Mail ${field} must not contain CR or LF.`);
|
|
127
128
|
}
|
|
129
|
+
for (const character of value) {
|
|
130
|
+
const code = character.charCodeAt(0);
|
|
131
|
+
if (code < 32 && code !== 9 || code === 127) {
|
|
132
|
+
throw new Error(`Mail ${field} must not contain control characters.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
128
135
|
return value;
|
|
129
136
|
}
|
|
130
137
|
function smtpEnvelopeAddress(value, field) {
|
|
@@ -145,148 +152,307 @@ function redactMailBody(body) {
|
|
|
145
152
|
function encodeBase64(value) {
|
|
146
153
|
return Buffer.from(value, "utf8").toString("base64");
|
|
147
154
|
}
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
while (remainder.includes(`\r
|
|
152
|
-
`)) {
|
|
153
|
-
const index = remainder.indexOf(`\r
|
|
154
|
-
`);
|
|
155
|
-
const line = remainder.slice(0, index);
|
|
156
|
-
remainder = remainder.slice(index + 2);
|
|
157
|
-
if (line.length >= 4 && line[3] === "-") {
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
responses.push(line);
|
|
155
|
+
function smtpTimeout(value = 1e4) {
|
|
156
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647) {
|
|
157
|
+
throw new RangeError("SMTP timeoutMs must be a positive integer within the timer range.");
|
|
161
158
|
}
|
|
162
|
-
return
|
|
159
|
+
return value;
|
|
163
160
|
}
|
|
164
|
-
async function
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
throw new Error(`Unexpected SMTP response: ${response}`);
|
|
161
|
+
async function defaultSmtpTransport(config, message) {
|
|
162
|
+
const timeoutMs = smtpTimeout(config.timeoutMs);
|
|
163
|
+
if (!config.host.trim() || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
164
|
+
throw new RangeError("SMTP requires a host and a port between 1 and 65535.");
|
|
169
165
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
if (Boolean(config.username) !== Boolean(config.password)) {
|
|
167
|
+
throw new Error("SMTP authentication requires both username and password.");
|
|
168
|
+
}
|
|
169
|
+
if (config.username)
|
|
170
|
+
assertSafeSmtpField(config.username, "username");
|
|
171
|
+
if (config.password)
|
|
172
|
+
assertSafeSmtpField(config.password, "password");
|
|
173
|
+
const fromHeader = assertSafeSmtpAddress(config.from, "from");
|
|
174
|
+
const toHeader = assertSafeSmtpAddress(message.to, "to");
|
|
175
|
+
assertSafeSmtpField(message.subject, "subject");
|
|
176
|
+
const fromEnvelope = smtpEnvelopeAddress(config.from, "from");
|
|
177
|
+
const toEnvelope = smtpEnvelopeAddress(message.to, "to");
|
|
178
|
+
if (/[^\x21-\x7e]/u.test(fromEnvelope + toEnvelope)) {
|
|
179
|
+
throw new Error("Default SMTP transport requires ASCII envelopes; SMTPUTF8 is not negotiated.");
|
|
180
|
+
}
|
|
181
|
+
for (const address of [fromEnvelope, toEnvelope]) {
|
|
182
|
+
const at = address.lastIndexOf("@");
|
|
183
|
+
if (Buffer.byteLength(address) > 254 || Buffer.byteLength(address.slice(0, at)) > 64) {
|
|
184
|
+
throw new RangeError("SMTP envelope address exceeds the path/local-part length limit.");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
message.signal?.throwIfAborted();
|
|
188
|
+
let socket;
|
|
189
|
+
let finished = false;
|
|
190
|
+
let failed = false;
|
|
191
|
+
let failure;
|
|
192
|
+
let rejectFailure;
|
|
193
|
+
const failurePromise = new Promise((_, reject) => {
|
|
194
|
+
rejectFailure = reject;
|
|
195
|
+
});
|
|
196
|
+
failurePromise.catch(() => {});
|
|
197
|
+
const responses = [];
|
|
174
198
|
const waiters = [];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
199
|
+
let drain;
|
|
200
|
+
let drainVersion = 0;
|
|
201
|
+
let buffer = "";
|
|
202
|
+
let multilineCode;
|
|
203
|
+
let multilineLines = 0;
|
|
204
|
+
const decoder = new TextDecoder;
|
|
205
|
+
const fail = (reason) => {
|
|
206
|
+
if (finished || failed)
|
|
180
207
|
return;
|
|
208
|
+
failed = true;
|
|
209
|
+
failure = reason;
|
|
210
|
+
rejectFailure(reason);
|
|
211
|
+
waiters.length = 0;
|
|
212
|
+
drain?.();
|
|
213
|
+
drain = undefined;
|
|
214
|
+
socket?.terminate();
|
|
215
|
+
};
|
|
216
|
+
const onAbort = () => fail(message.signal?.reason ?? new DOMException("Mail send aborted.", "AbortError"));
|
|
217
|
+
const timer = setTimeout(() => fail(new DOMException("SMTP send deadline exceeded.", "TimeoutError")), timeoutMs);
|
|
218
|
+
message.signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
if (message.signal?.aborted)
|
|
220
|
+
onAbort();
|
|
221
|
+
const readResponse = async (expected) => {
|
|
222
|
+
const queued = responses.shift();
|
|
223
|
+
const response = queued ?? await Promise.race([
|
|
224
|
+
failed ? Promise.reject(failure) : new Promise((resolve) => waiters.push(resolve)),
|
|
225
|
+
failurePromise
|
|
226
|
+
]);
|
|
227
|
+
const code = response.slice(0, 3);
|
|
228
|
+
if (!expected.includes(code))
|
|
229
|
+
throw new Error(`Unexpected SMTP response code: ${code}`);
|
|
230
|
+
};
|
|
231
|
+
const write = async (text) => {
|
|
232
|
+
const bytes = Buffer.from(text, "utf8");
|
|
233
|
+
let offset = 0;
|
|
234
|
+
while (offset < bytes.length) {
|
|
235
|
+
if (failed)
|
|
236
|
+
throw failure;
|
|
237
|
+
const version = drainVersion;
|
|
238
|
+
if (!socket)
|
|
239
|
+
throw new Error("SMTP socket is not connected.");
|
|
240
|
+
const written = socket.write(bytes.subarray(offset));
|
|
241
|
+
if (written < 0)
|
|
242
|
+
throw new Error("SMTP socket write failed.");
|
|
243
|
+
offset += written;
|
|
244
|
+
if (offset < bytes.length && version === drainVersion) {
|
|
245
|
+
await Promise.race([
|
|
246
|
+
new Promise((resolve) => {
|
|
247
|
+
drain = resolve;
|
|
248
|
+
}),
|
|
249
|
+
failurePromise
|
|
250
|
+
]);
|
|
251
|
+
}
|
|
181
252
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
buffer = parsed.remainder;
|
|
193
|
-
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
194
|
-
const response = parsed.responses.shift();
|
|
195
|
-
waiters.shift()?.resolve(response);
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
const connecting = Bun.connect({
|
|
256
|
+
hostname: config.host,
|
|
257
|
+
port: config.port,
|
|
258
|
+
...config.secure ? {
|
|
259
|
+
tls: {
|
|
260
|
+
...config.tls,
|
|
261
|
+
serverName: config.tls?.serverName ?? config.host,
|
|
262
|
+
rejectUnauthorized: true
|
|
196
263
|
}
|
|
197
|
-
},
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
264
|
+
} : {},
|
|
265
|
+
socket: {
|
|
266
|
+
open(value) {
|
|
267
|
+
socket = value;
|
|
268
|
+
if (failed || finished)
|
|
269
|
+
value.terminate();
|
|
270
|
+
},
|
|
271
|
+
data(_socket, chunk) {
|
|
272
|
+
if (failed || finished)
|
|
273
|
+
return;
|
|
274
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
275
|
+
if (Buffer.byteLength(buffer, "utf8") > 16384) {
|
|
276
|
+
fail(new Error("SMTP response exceeded the buffer limit."));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
while (buffer.includes(`\r
|
|
280
|
+
`)) {
|
|
281
|
+
const index = buffer.indexOf(`\r
|
|
282
|
+
`);
|
|
283
|
+
const line = buffer.slice(0, index);
|
|
284
|
+
buffer = buffer.slice(index + 2);
|
|
285
|
+
const match = /^([2-5]\d{2})([- ]|$)/.exec(line);
|
|
286
|
+
if (!match || multilineCode !== undefined && match[1] !== multilineCode) {
|
|
287
|
+
fail(new Error("Malformed SMTP reply."));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (++multilineLines > 128) {
|
|
291
|
+
fail(new Error("SMTP reply exceeded the line limit."));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (match[2] === "-") {
|
|
295
|
+
multilineCode = match[1];
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
multilineCode = undefined;
|
|
299
|
+
multilineLines = 0;
|
|
300
|
+
const waiter = waiters.shift();
|
|
301
|
+
if (waiter)
|
|
302
|
+
waiter(line);
|
|
303
|
+
else {
|
|
304
|
+
responses.push(line);
|
|
305
|
+
if (responses.length > 32) {
|
|
306
|
+
fail(new Error("Too many buffered SMTP replies."));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
drain() {
|
|
313
|
+
drainVersion++;
|
|
314
|
+
const waiting = drain;
|
|
315
|
+
drain = undefined;
|
|
316
|
+
waiting?.();
|
|
317
|
+
},
|
|
318
|
+
end() {
|
|
319
|
+
fail(new Error("SMTP peer closed before the send completed."));
|
|
320
|
+
},
|
|
321
|
+
close() {
|
|
322
|
+
fail(new Error("SMTP connection closed before the send completed."));
|
|
323
|
+
},
|
|
324
|
+
error() {
|
|
325
|
+
fail(new Error("SMTP socket failed."));
|
|
326
|
+
},
|
|
327
|
+
connectError() {
|
|
328
|
+
fail(new Error("SMTP connection failed."));
|
|
329
|
+
},
|
|
330
|
+
handshake(_socket, success) {
|
|
331
|
+
if (!success)
|
|
332
|
+
fail(new Error("SMTP TLS verification failed."));
|
|
202
333
|
}
|
|
203
334
|
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
assertSafeSmtpField(config.password, "password");
|
|
214
|
-
}
|
|
215
|
-
const { socket, readResponse } = await openSmtpConnection(config);
|
|
216
|
-
try {
|
|
217
|
-
await waitForSmtpResponse(readResponse, ["220"]);
|
|
218
|
-
await socket.write(`EHLO ${smtpEhloHost()}\r
|
|
335
|
+
});
|
|
336
|
+
connecting.then((value) => {
|
|
337
|
+
socket = value;
|
|
338
|
+
if (failed || finished)
|
|
339
|
+
value.terminate();
|
|
340
|
+
}, () => {});
|
|
341
|
+
socket = await Promise.race([connecting, failurePromise]);
|
|
342
|
+
await readResponse(["220"]);
|
|
343
|
+
await write(`EHLO ${smtpEhloHost()}\r
|
|
219
344
|
`);
|
|
220
|
-
await
|
|
345
|
+
await readResponse(["250"]);
|
|
221
346
|
if (config.username && config.password) {
|
|
222
|
-
await
|
|
347
|
+
await write(`AUTH LOGIN\r
|
|
223
348
|
`);
|
|
224
|
-
await
|
|
225
|
-
await
|
|
349
|
+
await readResponse(["334"]);
|
|
350
|
+
await write(`${encodeBase64(config.username)}\r
|
|
226
351
|
`);
|
|
227
|
-
await
|
|
228
|
-
await
|
|
352
|
+
await readResponse(["334"]);
|
|
353
|
+
await write(`${encodeBase64(config.password)}\r
|
|
229
354
|
`);
|
|
230
|
-
await
|
|
355
|
+
await readResponse(["235"]);
|
|
231
356
|
}
|
|
232
|
-
|
|
233
|
-
const toHeader = assertSafeSmtpAddress(message.to, "to");
|
|
234
|
-
const from = smtpEnvelopeAddress(config.from, "from");
|
|
235
|
-
const to = smtpEnvelopeAddress(message.to, "to");
|
|
236
|
-
await socket.write(`MAIL FROM:<${from}>\r
|
|
357
|
+
await write(`MAIL FROM:<${fromEnvelope}>\r
|
|
237
358
|
`);
|
|
238
|
-
await
|
|
239
|
-
await
|
|
359
|
+
await readResponse(["250"]);
|
|
360
|
+
await write(`RCPT TO:<${toEnvelope}>\r
|
|
240
361
|
`);
|
|
241
|
-
await
|
|
242
|
-
await
|
|
362
|
+
await readResponse(["250", "251"]);
|
|
363
|
+
await write(`DATA\r
|
|
243
364
|
`);
|
|
244
|
-
await
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
await socket.write(payload);
|
|
251
|
-
await waitForSmtpResponse(readResponse, ["250"]);
|
|
252
|
-
await socket.write(`QUIT\r
|
|
365
|
+
await readResponse(["354"]);
|
|
366
|
+
await write(buildSmtpPayload(fromHeader, { ...message, to: toHeader }));
|
|
367
|
+
await readResponse(["250"]);
|
|
368
|
+
finished = true;
|
|
369
|
+
try {
|
|
370
|
+
socket.write(`QUIT\r
|
|
253
371
|
`);
|
|
254
|
-
|
|
372
|
+
} catch {}
|
|
255
373
|
} finally {
|
|
256
|
-
|
|
257
|
-
|
|
374
|
+
finished = true;
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
message.signal?.removeEventListener("abort", onAbort);
|
|
377
|
+
waiters.length = 0;
|
|
378
|
+
responses.length = 0;
|
|
379
|
+
drain = undefined;
|
|
380
|
+
socket?.end();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function encodeSmtpHeader(value) {
|
|
384
|
+
if (/^[\x20-\x7e]{0,60}$/u.test(value))
|
|
385
|
+
return value;
|
|
386
|
+
const words = [];
|
|
387
|
+
let part = "";
|
|
388
|
+
for (const character of value) {
|
|
389
|
+
if (Buffer.byteLength(part + character, "utf8") > 45) {
|
|
390
|
+
words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
|
|
391
|
+
part = "";
|
|
392
|
+
}
|
|
393
|
+
part += character;
|
|
394
|
+
}
|
|
395
|
+
if (part)
|
|
396
|
+
words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
|
|
397
|
+
return words.join(`\r
|
|
398
|
+
`);
|
|
399
|
+
}
|
|
400
|
+
function smtpAddressHeader(value) {
|
|
401
|
+
if (!value.includes("<"))
|
|
402
|
+
return value;
|
|
403
|
+
const name = value.slice(0, value.lastIndexOf("<")).trim();
|
|
404
|
+
const address = smtpEnvelopeAddress(value, "address");
|
|
405
|
+
if (!name)
|
|
406
|
+
return address;
|
|
407
|
+
const encoded = encodeSmtpHeader(name);
|
|
408
|
+
const display = encoded.startsWith("=?UTF-8?B?") ? encoded : `"${encoded.replace(/[\\"]/gu, "\\$&")}"`;
|
|
409
|
+
return `${display} <${address}>`;
|
|
410
|
+
}
|
|
411
|
+
function smtpMimePart(type, value) {
|
|
412
|
+
const normalized = value.replace(/\r\n|\r|\n/gu, `\r
|
|
413
|
+
`);
|
|
414
|
+
const base64 = /[^\t\r\n\x20-\x7e]/u.test(normalized) || normalized.split(`\r
|
|
415
|
+
`).some((line) => line.length > 998);
|
|
416
|
+
return [
|
|
417
|
+
`Content-Type: ${type}; charset=utf-8`,
|
|
418
|
+
`Content-Transfer-Encoding: ${base64 ? "base64" : "7bit"}`,
|
|
419
|
+
"",
|
|
420
|
+
base64 ? (encodeBase64(normalized).match(/.{1,76}/gu) ?? []).join(`\r
|
|
421
|
+
`) : normalized
|
|
422
|
+
];
|
|
258
423
|
}
|
|
259
424
|
function buildSmtpPayload(from, message) {
|
|
260
|
-
const safeFrom = assertSafeSmtpAddress(from, "from");
|
|
261
|
-
const safeTo = assertSafeSmtpAddress(message.to, "to");
|
|
262
|
-
const safeSubject = assertSafeSmtpField(message.subject, "subject");
|
|
263
425
|
const headers = [
|
|
264
|
-
`From: ${
|
|
265
|
-
`To: ${
|
|
266
|
-
`Subject: ${
|
|
426
|
+
`From: ${smtpAddressHeader(assertSafeSmtpAddress(from, "from"))}`,
|
|
427
|
+
`To: ${smtpAddressHeader(assertSafeSmtpAddress(message.to, "to"))}`,
|
|
428
|
+
`Subject: ${encodeSmtpHeader(assertSafeSmtpField(message.subject, "subject"))}`,
|
|
267
429
|
"MIME-Version: 1.0"
|
|
268
430
|
];
|
|
431
|
+
let lines;
|
|
269
432
|
if (message.html) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
433
|
+
let boundary;
|
|
434
|
+
do {
|
|
435
|
+
boundary = `strata-${crypto.randomUUID()}`;
|
|
436
|
+
} while (message.body.includes(boundary) || message.html.includes(boundary));
|
|
437
|
+
lines = [
|
|
438
|
+
...headers,
|
|
439
|
+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
275
440
|
"",
|
|
276
|
-
message.body,
|
|
277
441
|
`--${boundary}`,
|
|
278
|
-
"
|
|
279
|
-
|
|
280
|
-
message.html,
|
|
442
|
+
...smtpMimePart("text/plain", message.body),
|
|
443
|
+
`--${boundary}`,
|
|
444
|
+
...smtpMimePart("text/html", message.html),
|
|
281
445
|
`--${boundary}--`,
|
|
282
446
|
""
|
|
283
447
|
];
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return
|
|
289
|
-
`)
|
|
448
|
+
} else
|
|
449
|
+
lines = [...headers, ...smtpMimePart("text/plain", message.body)];
|
|
450
|
+
const content = lines.join(`\r
|
|
451
|
+
`).replace(/(^|\r\n)\./gu, "$1..");
|
|
452
|
+
return `${content}${content.endsWith(`\r
|
|
453
|
+
`) ? "" : `\r
|
|
454
|
+
`}.\r
|
|
455
|
+
`;
|
|
290
456
|
}
|
|
291
457
|
|
|
292
458
|
class LogMailDriver {
|