@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
|
@@ -191,15 +191,11 @@ BEGIN
|
|
|
191
191
|
END
|
|
192
192
|
$$`;
|
|
193
193
|
}
|
|
194
|
-
|
|
194
|
+
var POSTGRES_PRIVILEGE_LOCK_SQL = "SELECT pg_advisory_xact_lock(hashtext('strata:postgres-privileges'), hashtext(current_database()))";
|
|
195
|
+
function postgresAppRolePrivilegeSql(options) {
|
|
195
196
|
const database = assertSafeSqlIdentifier(options.database, "database name");
|
|
196
197
|
const role = assertSafeSqlIdentifier(options.role ?? POSTGRES_APP_ROLE, "role name");
|
|
197
|
-
return
|
|
198
|
-
-- Superuser remains for CREATE ROLE / CREATE DATABASE / GRANT / migrate / migrate:fresh DROP.
|
|
199
|
-
-- This script is repeatable on an already-existing volume (not only docker-entrypoint-initdb.d).
|
|
200
|
-
${postgresAppRoleCreateSql(options)};
|
|
201
|
-
|
|
202
|
-
GRANT CONNECT ON DATABASE ${database} TO ${role};
|
|
198
|
+
return `GRANT CONNECT ON DATABASE ${database} TO ${role};
|
|
203
199
|
GRANT USAGE, CREATE ON SCHEMA public TO ${role};
|
|
204
200
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${role};
|
|
205
201
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${role};
|
|
@@ -209,17 +205,21 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${role};
|
|
|
209
205
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${role};
|
|
210
206
|
`;
|
|
211
207
|
}
|
|
208
|
+
function postgresAppRoleSql(options) {
|
|
209
|
+
const privileges = postgresAppRolePrivilegeSql(options);
|
|
210
|
+
return `-- Application login role. FORCE RLS applies because this role is NOSUPERUSER and NOBYPASSRLS.
|
|
211
|
+
-- Superuser remains for CREATE ROLE / CREATE DATABASE / GRANT / migrate / migrate:fresh DROP.
|
|
212
|
+
-- This script is repeatable on an already-existing volume (not only docker-entrypoint-initdb.d).
|
|
213
|
+
${POSTGRES_PRIVILEGE_LOCK_SQL};
|
|
214
|
+
${postgresAppRoleCreateSql(options)};
|
|
215
|
+
|
|
216
|
+
${privileges}
|
|
217
|
+
`;
|
|
218
|
+
}
|
|
212
219
|
async function grantPostgresAppRolePrivileges(sql, options) {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
await sql.unsafe(`GRANT USAGE, CREATE ON SCHEMA public TO ${role}`);
|
|
217
|
-
await sql.unsafe(`GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${role}`);
|
|
218
|
-
await sql.unsafe(`GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${role}`);
|
|
219
|
-
await sql.unsafe(`GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${role}`);
|
|
220
|
-
await sql.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${role}`);
|
|
221
|
-
await sql.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${role}`);
|
|
222
|
-
await sql.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${role}`);
|
|
220
|
+
const privileges = postgresAppRolePrivilegeSql(options);
|
|
221
|
+
await sql.unsafe(`${POSTGRES_PRIVILEGE_LOCK_SQL};
|
|
222
|
+
${privileges}`);
|
|
223
223
|
}
|
|
224
224
|
async function ensurePostgresAppRole(sql, options) {
|
|
225
225
|
await sql.unsafe(postgresAppRoleSql(options));
|
|
@@ -651,6 +651,7 @@ var init_repositoryConnection = __esm(() => {
|
|
|
651
651
|
});
|
|
652
652
|
|
|
653
653
|
// ../../src/core/database/transaction.ts
|
|
654
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
654
655
|
async function runInSavepoint(connection, operation) {
|
|
655
656
|
const inherited = transactionFrame.getStore();
|
|
656
657
|
let parent = inherited?.connection === connection ? inherited : outerFrames.get(connection);
|
|
@@ -710,9 +711,9 @@ async function runInTransaction(operation, options = {}) {
|
|
|
710
711
|
signal?.throwIfAborted();
|
|
711
712
|
if (!supportsTransactions(connection))
|
|
712
713
|
throw new Error("Reserved database connection does not support transactions.");
|
|
713
|
-
return await connection.begin(async (transaction) => {
|
|
714
|
+
return await connection.begin(AsyncLocalStorage2.bind(async (transaction) => {
|
|
714
715
|
return await runWithDatabaseConnection(transaction, () => transactionFrame.run({ connection: transaction, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await admittedOperation(createDatabaseConnection(transaction))))));
|
|
715
|
-
});
|
|
716
|
+
}));
|
|
716
717
|
} finally {
|
|
717
718
|
await reserved?.release();
|
|
718
719
|
}
|
|
@@ -756,6 +757,7 @@ var init_tenancyConfig = __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/index.js
CHANGED
|
@@ -807,6 +807,7 @@ function createDatabaseConnection(source) {
|
|
|
807
807
|
var init_connection = () => {};
|
|
808
808
|
|
|
809
809
|
// ../../src/core/database/transaction.ts
|
|
810
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
810
811
|
async function runInSavepoint(connection, operation) {
|
|
811
812
|
const inherited = transactionFrame.getStore();
|
|
812
813
|
let parent = inherited?.connection === connection ? inherited : outerFrames.get(connection);
|
|
@@ -866,9 +867,9 @@ async function runInTransaction(operation, options = {}) {
|
|
|
866
867
|
signal?.throwIfAborted();
|
|
867
868
|
if (!supportsTransactions(connection))
|
|
868
869
|
throw new Error("Reserved database connection does not support transactions.");
|
|
869
|
-
return await connection.begin(async (transaction) => {
|
|
870
|
+
return await connection.begin(AsyncLocalStorage2.bind(async (transaction) => {
|
|
870
871
|
return await runWithDatabaseConnection(transaction, () => transactionFrame.run({ connection: transaction, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await admittedOperation(createDatabaseConnection(transaction))))));
|
|
871
|
-
});
|
|
872
|
+
}));
|
|
872
873
|
} finally {
|
|
873
874
|
await reserved?.release();
|
|
874
875
|
}
|
|
@@ -889,6 +890,7 @@ var init_transaction = __esm(() => {
|
|
|
889
890
|
});
|
|
890
891
|
|
|
891
892
|
// ../../src/core/tenant/databaseTenantContext.ts
|
|
893
|
+
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
892
894
|
async function applyBypassToTransaction(transaction, bypass) {
|
|
893
895
|
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, [
|
|
894
896
|
bypass ? "true" : "false"
|
|
@@ -940,10 +942,10 @@ async function runWithScopedTenantTransaction(apply, callback) {
|
|
|
940
942
|
throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
941
943
|
}
|
|
942
944
|
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
943
|
-
return await begin.bind(pool)(async (transaction) => {
|
|
945
|
+
return await begin.bind(pool)(AsyncLocalStorage3.bind(async (transaction) => {
|
|
944
946
|
await apply(transaction);
|
|
945
947
|
return await runWithDatabaseConnection(transaction, () => runWithTransactionScope(async () => commitOrRollbackScope(await callback())));
|
|
946
|
-
});
|
|
948
|
+
}));
|
|
947
949
|
}));
|
|
948
950
|
}
|
|
949
951
|
async function runWithMigrationBypass(callback) {
|
|
@@ -963,6 +965,7 @@ var init_databaseTenantContext = __esm(() => {
|
|
|
963
965
|
});
|
|
964
966
|
|
|
965
967
|
// ../../src/core/tenant/tenantDatabaseScope.ts
|
|
968
|
+
import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
966
969
|
async function applyTenantContextToTransaction(transaction, tenantId) {
|
|
967
970
|
await transaction.unsafe(`SELECT set_config('app.tenant_id', $1, true)`, [String(tenantId)]);
|
|
968
971
|
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, ["false"]);
|
|
@@ -985,12 +988,12 @@ async function runWithTenantDatabase(tenant, callback) {
|
|
|
985
988
|
throw new Error("RLS tenant scope requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
986
989
|
}
|
|
987
990
|
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
988
|
-
return await begin.bind(pool)(async (transaction) => {
|
|
991
|
+
return await begin.bind(pool)(AsyncLocalStorage4.bind(async (transaction) => {
|
|
989
992
|
await applyTenantContextToTransaction(transaction, tenant.id);
|
|
990
993
|
return await runWithDatabaseConnection(transaction, async () => {
|
|
991
994
|
return await runWithTransactionScope(async () => commitOrRollbackScope(await runWithTenant(tenant, callback)));
|
|
992
995
|
});
|
|
993
|
-
});
|
|
996
|
+
}));
|
|
994
997
|
}));
|
|
995
998
|
}
|
|
996
999
|
function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
|
|
@@ -10638,8 +10641,8 @@ function resolveSmtpConfig() {
|
|
|
10638
10641
|
if (!from) {
|
|
10639
10642
|
throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
|
|
10640
10643
|
}
|
|
10641
|
-
const port = Number
|
|
10642
|
-
if (!Number.isInteger(port) || port <= 0) {
|
|
10644
|
+
const port = Number(process.env.MAIL_PORT ?? "587");
|
|
10645
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
10643
10646
|
throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
|
|
10644
10647
|
}
|
|
10645
10648
|
return {
|
|
@@ -10647,6 +10650,7 @@ function resolveSmtpConfig() {
|
|
|
10647
10650
|
port,
|
|
10648
10651
|
from,
|
|
10649
10652
|
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
10653
|
+
...process.env.MAIL_TIMEOUT_MS !== undefined ? { timeoutMs: smtpTimeout(Number(process.env.MAIL_TIMEOUT_MS)) } : {},
|
|
10650
10654
|
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
10651
10655
|
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
10652
10656
|
};
|
|
@@ -10656,6 +10660,12 @@ function assertSafeSmtpField(value, field) {
|
|
|
10656
10660
|
if (/[\r\n]/u.test(value)) {
|
|
10657
10661
|
throw new Error(`Mail ${field} must not contain CR or LF.`);
|
|
10658
10662
|
}
|
|
10663
|
+
for (const character of value) {
|
|
10664
|
+
const code = character.charCodeAt(0);
|
|
10665
|
+
if (code < 32 && code !== 9 || code === 127) {
|
|
10666
|
+
throw new Error(`Mail ${field} must not contain control characters.`);
|
|
10667
|
+
}
|
|
10668
|
+
}
|
|
10659
10669
|
return value;
|
|
10660
10670
|
}
|
|
10661
10671
|
function smtpEnvelopeAddress(value, field) {
|
|
@@ -10676,148 +10686,307 @@ function redactMailBody(body) {
|
|
|
10676
10686
|
function encodeBase64(value) {
|
|
10677
10687
|
return Buffer.from(value, "utf8").toString("base64");
|
|
10678
10688
|
}
|
|
10679
|
-
function
|
|
10680
|
-
|
|
10681
|
-
|
|
10682
|
-
while (remainder.includes(`\r
|
|
10683
|
-
`)) {
|
|
10684
|
-
const index = remainder.indexOf(`\r
|
|
10685
|
-
`);
|
|
10686
|
-
const line = remainder.slice(0, index);
|
|
10687
|
-
remainder = remainder.slice(index + 2);
|
|
10688
|
-
if (line.length >= 4 && line[3] === "-") {
|
|
10689
|
-
continue;
|
|
10690
|
-
}
|
|
10691
|
-
responses.push(line);
|
|
10689
|
+
function smtpTimeout(value = 1e4) {
|
|
10690
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647) {
|
|
10691
|
+
throw new RangeError("SMTP timeoutMs must be a positive integer within the timer range.");
|
|
10692
10692
|
}
|
|
10693
|
-
return
|
|
10693
|
+
return value;
|
|
10694
10694
|
}
|
|
10695
|
-
async function
|
|
10696
|
-
const
|
|
10697
|
-
|
|
10698
|
-
|
|
10699
|
-
throw new Error(`Unexpected SMTP response: ${response}`);
|
|
10695
|
+
async function defaultSmtpTransport(config, message) {
|
|
10696
|
+
const timeoutMs = smtpTimeout(config.timeoutMs);
|
|
10697
|
+
if (!config.host.trim() || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
10698
|
+
throw new RangeError("SMTP requires a host and a port between 1 and 65535.");
|
|
10700
10699
|
}
|
|
10701
|
-
|
|
10702
|
-
|
|
10703
|
-
|
|
10704
|
-
|
|
10700
|
+
if (Boolean(config.username) !== Boolean(config.password)) {
|
|
10701
|
+
throw new Error("SMTP authentication requires both username and password.");
|
|
10702
|
+
}
|
|
10703
|
+
if (config.username)
|
|
10704
|
+
assertSafeSmtpField(config.username, "username");
|
|
10705
|
+
if (config.password)
|
|
10706
|
+
assertSafeSmtpField(config.password, "password");
|
|
10707
|
+
const fromHeader = assertSafeSmtpAddress(config.from, "from");
|
|
10708
|
+
const toHeader = assertSafeSmtpAddress(message.to, "to");
|
|
10709
|
+
assertSafeSmtpField(message.subject, "subject");
|
|
10710
|
+
const fromEnvelope = smtpEnvelopeAddress(config.from, "from");
|
|
10711
|
+
const toEnvelope = smtpEnvelopeAddress(message.to, "to");
|
|
10712
|
+
if (/[^\x21-\x7e]/u.test(fromEnvelope + toEnvelope)) {
|
|
10713
|
+
throw new Error("Default SMTP transport requires ASCII envelopes; SMTPUTF8 is not negotiated.");
|
|
10714
|
+
}
|
|
10715
|
+
for (const address of [fromEnvelope, toEnvelope]) {
|
|
10716
|
+
const at = address.lastIndexOf("@");
|
|
10717
|
+
if (Buffer.byteLength(address) > 254 || Buffer.byteLength(address.slice(0, at)) > 64) {
|
|
10718
|
+
throw new RangeError("SMTP envelope address exceeds the path/local-part length limit.");
|
|
10719
|
+
}
|
|
10720
|
+
}
|
|
10721
|
+
message.signal?.throwIfAborted();
|
|
10722
|
+
let socket;
|
|
10723
|
+
let finished = false;
|
|
10724
|
+
let failed = false;
|
|
10725
|
+
let failure;
|
|
10726
|
+
let rejectFailure;
|
|
10727
|
+
const failurePromise = new Promise((_, reject) => {
|
|
10728
|
+
rejectFailure = reject;
|
|
10729
|
+
});
|
|
10730
|
+
failurePromise.catch(() => {});
|
|
10731
|
+
const responses = [];
|
|
10705
10732
|
const waiters = [];
|
|
10706
|
-
|
|
10707
|
-
|
|
10708
|
-
|
|
10709
|
-
|
|
10710
|
-
|
|
10733
|
+
let drain;
|
|
10734
|
+
let drainVersion = 0;
|
|
10735
|
+
let buffer = "";
|
|
10736
|
+
let multilineCode;
|
|
10737
|
+
let multilineLines = 0;
|
|
10738
|
+
const decoder = new TextDecoder;
|
|
10739
|
+
const fail = (reason) => {
|
|
10740
|
+
if (finished || failed)
|
|
10711
10741
|
return;
|
|
10742
|
+
failed = true;
|
|
10743
|
+
failure = reason;
|
|
10744
|
+
rejectFailure(reason);
|
|
10745
|
+
waiters.length = 0;
|
|
10746
|
+
drain?.();
|
|
10747
|
+
drain = undefined;
|
|
10748
|
+
socket?.terminate();
|
|
10749
|
+
};
|
|
10750
|
+
const onAbort = () => fail(message.signal?.reason ?? new DOMException("Mail send aborted.", "AbortError"));
|
|
10751
|
+
const timer = setTimeout(() => fail(new DOMException("SMTP send deadline exceeded.", "TimeoutError")), timeoutMs);
|
|
10752
|
+
message.signal?.addEventListener("abort", onAbort, { once: true });
|
|
10753
|
+
if (message.signal?.aborted)
|
|
10754
|
+
onAbort();
|
|
10755
|
+
const readResponse = async (expected) => {
|
|
10756
|
+
const queued = responses.shift();
|
|
10757
|
+
const response = queued ?? await Promise.race([
|
|
10758
|
+
failed ? Promise.reject(failure) : new Promise((resolve) => waiters.push(resolve)),
|
|
10759
|
+
failurePromise
|
|
10760
|
+
]);
|
|
10761
|
+
const code = response.slice(0, 3);
|
|
10762
|
+
if (!expected.includes(code))
|
|
10763
|
+
throw new Error(`Unexpected SMTP response code: ${code}`);
|
|
10764
|
+
};
|
|
10765
|
+
const write = async (text) => {
|
|
10766
|
+
const bytes = Buffer.from(text, "utf8");
|
|
10767
|
+
let offset = 0;
|
|
10768
|
+
while (offset < bytes.length) {
|
|
10769
|
+
if (failed)
|
|
10770
|
+
throw failure;
|
|
10771
|
+
const version = drainVersion;
|
|
10772
|
+
if (!socket)
|
|
10773
|
+
throw new Error("SMTP socket is not connected.");
|
|
10774
|
+
const written = socket.write(bytes.subarray(offset));
|
|
10775
|
+
if (written < 0)
|
|
10776
|
+
throw new Error("SMTP socket write failed.");
|
|
10777
|
+
offset += written;
|
|
10778
|
+
if (offset < bytes.length && version === drainVersion) {
|
|
10779
|
+
await Promise.race([
|
|
10780
|
+
new Promise((resolve) => {
|
|
10781
|
+
drain = resolve;
|
|
10782
|
+
}),
|
|
10783
|
+
failurePromise
|
|
10784
|
+
]);
|
|
10785
|
+
}
|
|
10712
10786
|
}
|
|
10713
|
-
|
|
10714
|
-
|
|
10715
|
-
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10720
|
-
|
|
10721
|
-
|
|
10722
|
-
|
|
10723
|
-
buffer = parsed.remainder;
|
|
10724
|
-
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
10725
|
-
const response = parsed.responses.shift();
|
|
10726
|
-
waiters.shift()?.resolve(response);
|
|
10787
|
+
};
|
|
10788
|
+
try {
|
|
10789
|
+
const connecting = Bun.connect({
|
|
10790
|
+
hostname: config.host,
|
|
10791
|
+
port: config.port,
|
|
10792
|
+
...config.secure ? {
|
|
10793
|
+
tls: {
|
|
10794
|
+
...config.tls,
|
|
10795
|
+
serverName: config.tls?.serverName ?? config.host,
|
|
10796
|
+
rejectUnauthorized: true
|
|
10727
10797
|
}
|
|
10728
|
-
},
|
|
10729
|
-
|
|
10730
|
-
|
|
10731
|
-
|
|
10732
|
-
|
|
10798
|
+
} : {},
|
|
10799
|
+
socket: {
|
|
10800
|
+
open(value) {
|
|
10801
|
+
socket = value;
|
|
10802
|
+
if (failed || finished)
|
|
10803
|
+
value.terminate();
|
|
10804
|
+
},
|
|
10805
|
+
data(_socket, chunk) {
|
|
10806
|
+
if (failed || finished)
|
|
10807
|
+
return;
|
|
10808
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
10809
|
+
if (Buffer.byteLength(buffer, "utf8") > 16384) {
|
|
10810
|
+
fail(new Error("SMTP response exceeded the buffer limit."));
|
|
10811
|
+
return;
|
|
10812
|
+
}
|
|
10813
|
+
while (buffer.includes(`\r
|
|
10814
|
+
`)) {
|
|
10815
|
+
const index = buffer.indexOf(`\r
|
|
10816
|
+
`);
|
|
10817
|
+
const line = buffer.slice(0, index);
|
|
10818
|
+
buffer = buffer.slice(index + 2);
|
|
10819
|
+
const match = /^([2-5]\d{2})([- ]|$)/.exec(line);
|
|
10820
|
+
if (!match || multilineCode !== undefined && match[1] !== multilineCode) {
|
|
10821
|
+
fail(new Error("Malformed SMTP reply."));
|
|
10822
|
+
return;
|
|
10823
|
+
}
|
|
10824
|
+
if (++multilineLines > 128) {
|
|
10825
|
+
fail(new Error("SMTP reply exceeded the line limit."));
|
|
10826
|
+
return;
|
|
10827
|
+
}
|
|
10828
|
+
if (match[2] === "-") {
|
|
10829
|
+
multilineCode = match[1];
|
|
10830
|
+
continue;
|
|
10831
|
+
}
|
|
10832
|
+
multilineCode = undefined;
|
|
10833
|
+
multilineLines = 0;
|
|
10834
|
+
const waiter = waiters.shift();
|
|
10835
|
+
if (waiter)
|
|
10836
|
+
waiter(line);
|
|
10837
|
+
else {
|
|
10838
|
+
responses.push(line);
|
|
10839
|
+
if (responses.length > 32) {
|
|
10840
|
+
fail(new Error("Too many buffered SMTP replies."));
|
|
10841
|
+
return;
|
|
10842
|
+
}
|
|
10843
|
+
}
|
|
10844
|
+
}
|
|
10845
|
+
},
|
|
10846
|
+
drain() {
|
|
10847
|
+
drainVersion++;
|
|
10848
|
+
const waiting = drain;
|
|
10849
|
+
drain = undefined;
|
|
10850
|
+
waiting?.();
|
|
10851
|
+
},
|
|
10852
|
+
end() {
|
|
10853
|
+
fail(new Error("SMTP peer closed before the send completed."));
|
|
10854
|
+
},
|
|
10855
|
+
close() {
|
|
10856
|
+
fail(new Error("SMTP connection closed before the send completed."));
|
|
10857
|
+
},
|
|
10858
|
+
error() {
|
|
10859
|
+
fail(new Error("SMTP socket failed."));
|
|
10860
|
+
},
|
|
10861
|
+
connectError() {
|
|
10862
|
+
fail(new Error("SMTP connection failed."));
|
|
10863
|
+
},
|
|
10864
|
+
handshake(_socket, success) {
|
|
10865
|
+
if (!success)
|
|
10866
|
+
fail(new Error("SMTP TLS verification failed."));
|
|
10733
10867
|
}
|
|
10734
10868
|
}
|
|
10735
|
-
}
|
|
10736
|
-
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
|
|
10740
|
-
|
|
10741
|
-
|
|
10742
|
-
|
|
10743
|
-
|
|
10744
|
-
assertSafeSmtpField(config.password, "password");
|
|
10745
|
-
}
|
|
10746
|
-
const { socket, readResponse } = await openSmtpConnection(config);
|
|
10747
|
-
try {
|
|
10748
|
-
await waitForSmtpResponse(readResponse, ["220"]);
|
|
10749
|
-
await socket.write(`EHLO ${smtpEhloHost()}\r
|
|
10869
|
+
});
|
|
10870
|
+
connecting.then((value) => {
|
|
10871
|
+
socket = value;
|
|
10872
|
+
if (failed || finished)
|
|
10873
|
+
value.terminate();
|
|
10874
|
+
}, () => {});
|
|
10875
|
+
socket = await Promise.race([connecting, failurePromise]);
|
|
10876
|
+
await readResponse(["220"]);
|
|
10877
|
+
await write(`EHLO ${smtpEhloHost()}\r
|
|
10750
10878
|
`);
|
|
10751
|
-
await
|
|
10879
|
+
await readResponse(["250"]);
|
|
10752
10880
|
if (config.username && config.password) {
|
|
10753
|
-
await
|
|
10881
|
+
await write(`AUTH LOGIN\r
|
|
10754
10882
|
`);
|
|
10755
|
-
await
|
|
10756
|
-
await
|
|
10883
|
+
await readResponse(["334"]);
|
|
10884
|
+
await write(`${encodeBase64(config.username)}\r
|
|
10757
10885
|
`);
|
|
10758
|
-
await
|
|
10759
|
-
await
|
|
10886
|
+
await readResponse(["334"]);
|
|
10887
|
+
await write(`${encodeBase64(config.password)}\r
|
|
10760
10888
|
`);
|
|
10761
|
-
await
|
|
10889
|
+
await readResponse(["235"]);
|
|
10762
10890
|
}
|
|
10763
|
-
|
|
10764
|
-
const toHeader = assertSafeSmtpAddress(message.to, "to");
|
|
10765
|
-
const from = smtpEnvelopeAddress(config.from, "from");
|
|
10766
|
-
const to = smtpEnvelopeAddress(message.to, "to");
|
|
10767
|
-
await socket.write(`MAIL FROM:<${from}>\r
|
|
10891
|
+
await write(`MAIL FROM:<${fromEnvelope}>\r
|
|
10768
10892
|
`);
|
|
10769
|
-
await
|
|
10770
|
-
await
|
|
10893
|
+
await readResponse(["250"]);
|
|
10894
|
+
await write(`RCPT TO:<${toEnvelope}>\r
|
|
10771
10895
|
`);
|
|
10772
|
-
await
|
|
10773
|
-
await
|
|
10896
|
+
await readResponse(["250", "251"]);
|
|
10897
|
+
await write(`DATA\r
|
|
10774
10898
|
`);
|
|
10775
|
-
await
|
|
10776
|
-
|
|
10777
|
-
|
|
10778
|
-
|
|
10779
|
-
|
|
10780
|
-
|
|
10781
|
-
await socket.write(payload);
|
|
10782
|
-
await waitForSmtpResponse(readResponse, ["250"]);
|
|
10783
|
-
await socket.write(`QUIT\r
|
|
10899
|
+
await readResponse(["354"]);
|
|
10900
|
+
await write(buildSmtpPayload(fromHeader, { ...message, to: toHeader }));
|
|
10901
|
+
await readResponse(["250"]);
|
|
10902
|
+
finished = true;
|
|
10903
|
+
try {
|
|
10904
|
+
socket.write(`QUIT\r
|
|
10784
10905
|
`);
|
|
10785
|
-
|
|
10906
|
+
} catch {}
|
|
10786
10907
|
} finally {
|
|
10787
|
-
|
|
10908
|
+
finished = true;
|
|
10909
|
+
clearTimeout(timer);
|
|
10910
|
+
message.signal?.removeEventListener("abort", onAbort);
|
|
10911
|
+
waiters.length = 0;
|
|
10912
|
+
responses.length = 0;
|
|
10913
|
+
drain = undefined;
|
|
10914
|
+
socket?.end();
|
|
10788
10915
|
}
|
|
10789
10916
|
}
|
|
10917
|
+
function encodeSmtpHeader(value) {
|
|
10918
|
+
if (/^[\x20-\x7e]{0,60}$/u.test(value))
|
|
10919
|
+
return value;
|
|
10920
|
+
const words = [];
|
|
10921
|
+
let part = "";
|
|
10922
|
+
for (const character of value) {
|
|
10923
|
+
if (Buffer.byteLength(part + character, "utf8") > 45) {
|
|
10924
|
+
words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
|
|
10925
|
+
part = "";
|
|
10926
|
+
}
|
|
10927
|
+
part += character;
|
|
10928
|
+
}
|
|
10929
|
+
if (part)
|
|
10930
|
+
words.push(`=?UTF-8?B?${encodeBase64(part)}?=`);
|
|
10931
|
+
return words.join(`\r
|
|
10932
|
+
`);
|
|
10933
|
+
}
|
|
10934
|
+
function smtpAddressHeader(value) {
|
|
10935
|
+
if (!value.includes("<"))
|
|
10936
|
+
return value;
|
|
10937
|
+
const name = value.slice(0, value.lastIndexOf("<")).trim();
|
|
10938
|
+
const address = smtpEnvelopeAddress(value, "address");
|
|
10939
|
+
if (!name)
|
|
10940
|
+
return address;
|
|
10941
|
+
const encoded = encodeSmtpHeader(name);
|
|
10942
|
+
const display = encoded.startsWith("=?UTF-8?B?") ? encoded : `"${encoded.replace(/[\\"]/gu, "\\$&")}"`;
|
|
10943
|
+
return `${display} <${address}>`;
|
|
10944
|
+
}
|
|
10945
|
+
function smtpMimePart(type, value) {
|
|
10946
|
+
const normalized = value.replace(/\r\n|\r|\n/gu, `\r
|
|
10947
|
+
`);
|
|
10948
|
+
const base64 = /[^\t\r\n\x20-\x7e]/u.test(normalized) || normalized.split(`\r
|
|
10949
|
+
`).some((line) => line.length > 998);
|
|
10950
|
+
return [
|
|
10951
|
+
`Content-Type: ${type}; charset=utf-8`,
|
|
10952
|
+
`Content-Transfer-Encoding: ${base64 ? "base64" : "7bit"}`,
|
|
10953
|
+
"",
|
|
10954
|
+
base64 ? (encodeBase64(normalized).match(/.{1,76}/gu) ?? []).join(`\r
|
|
10955
|
+
`) : normalized
|
|
10956
|
+
];
|
|
10957
|
+
}
|
|
10790
10958
|
function buildSmtpPayload(from, message) {
|
|
10791
|
-
const safeFrom = assertSafeSmtpAddress(from, "from");
|
|
10792
|
-
const safeTo = assertSafeSmtpAddress(message.to, "to");
|
|
10793
|
-
const safeSubject = assertSafeSmtpField(message.subject, "subject");
|
|
10794
10959
|
const headers = [
|
|
10795
|
-
`From: ${
|
|
10796
|
-
`To: ${
|
|
10797
|
-
`Subject: ${
|
|
10960
|
+
`From: ${smtpAddressHeader(assertSafeSmtpAddress(from, "from"))}`,
|
|
10961
|
+
`To: ${smtpAddressHeader(assertSafeSmtpAddress(message.to, "to"))}`,
|
|
10962
|
+
`Subject: ${encodeSmtpHeader(assertSafeSmtpField(message.subject, "subject"))}`,
|
|
10798
10963
|
"MIME-Version: 1.0"
|
|
10799
10964
|
];
|
|
10965
|
+
let lines;
|
|
10800
10966
|
if (message.html) {
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10967
|
+
let boundary;
|
|
10968
|
+
do {
|
|
10969
|
+
boundary = `strata-${crypto.randomUUID()}`;
|
|
10970
|
+
} while (message.body.includes(boundary) || message.html.includes(boundary));
|
|
10971
|
+
lines = [
|
|
10972
|
+
...headers,
|
|
10973
|
+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
10806
10974
|
"",
|
|
10807
|
-
message.body,
|
|
10808
10975
|
`--${boundary}`,
|
|
10809
|
-
"
|
|
10810
|
-
|
|
10811
|
-
message.html,
|
|
10976
|
+
...smtpMimePart("text/plain", message.body),
|
|
10977
|
+
`--${boundary}`,
|
|
10978
|
+
...smtpMimePart("text/html", message.html),
|
|
10812
10979
|
`--${boundary}--`,
|
|
10813
10980
|
""
|
|
10814
10981
|
];
|
|
10815
|
-
|
|
10816
|
-
|
|
10817
|
-
|
|
10818
|
-
|
|
10819
|
-
return
|
|
10820
|
-
`)
|
|
10982
|
+
} else
|
|
10983
|
+
lines = [...headers, ...smtpMimePart("text/plain", message.body)];
|
|
10984
|
+
const content = lines.join(`\r
|
|
10985
|
+
`).replace(/(^|\r\n)\./gu, "$1..");
|
|
10986
|
+
return `${content}${content.endsWith(`\r
|
|
10987
|
+
`) ? "" : `\r
|
|
10988
|
+
`}.\r
|
|
10989
|
+
`;
|
|
10821
10990
|
}
|
|
10822
10991
|
|
|
10823
10992
|
class LogMailDriver {
|