@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.
@@ -191,15 +191,11 @@ BEGIN
191
191
  END
192
192
  $$`;
193
193
  }
194
- function postgresAppRoleSql(options) {
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 `-- Application login role. FORCE RLS applies because this role is NOSUPERUSER and NOBYPASSRLS.
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 database = assertSafeSqlIdentifier(options.database, "database name");
214
- const role = assertSafeSqlIdentifier(options.role ?? POSTGRES_APP_ROLE, "role name");
215
- await sql.unsafe(`GRANT CONNECT ON DATABASE ${database} TO ${role}`);
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.parseInt(process.env.MAIL_PORT ?? "587", 10);
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 parseSmtpResponses(buffer) {
10680
- const responses = [];
10681
- let remainder = buffer;
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 { responses, remainder };
10693
+ return value;
10694
10694
  }
10695
- async function waitForSmtpResponse(readResponse, expectedCodes) {
10696
- const response = await readResponse();
10697
- const code = response.slice(0, 3);
10698
- if (!expectedCodes.includes(code)) {
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
- return response;
10702
- }
10703
- async function openSmtpConnection(config) {
10704
- let buffer = "";
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
- const readResponse = () => new Promise((resolve, reject) => {
10707
- const parsed = parseSmtpResponses(buffer);
10708
- if (parsed.responses.length > 0) {
10709
- buffer = parsed.remainder;
10710
- resolve(parsed.responses.shift());
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
- waiters.push({ resolve, reject });
10714
- });
10715
- const socket = await Bun.connect({
10716
- hostname: config.host,
10717
- port: config.port,
10718
- socket: {
10719
- open() {},
10720
- data(_socket, chunk) {
10721
- buffer += Buffer.from(chunk).toString("utf8");
10722
- const parsed = parseSmtpResponses(buffer);
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
- error(_socket, error) {
10730
- const pending = waiters.splice(0);
10731
- for (const waiter of pending) {
10732
- waiter.reject(error instanceof Error ? error : new Error(String(error)));
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
- return { socket, readResponse };
10738
- }
10739
- async function defaultSmtpTransport(config, message) {
10740
- if (config.username) {
10741
- assertSafeSmtpField(config.username, "username");
10742
- }
10743
- if (config.password) {
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 waitForSmtpResponse(readResponse, ["250"]);
10879
+ await readResponse(["250"]);
10752
10880
  if (config.username && config.password) {
10753
- await socket.write(`AUTH LOGIN\r
10881
+ await write(`AUTH LOGIN\r
10754
10882
  `);
10755
- await waitForSmtpResponse(readResponse, ["334"]);
10756
- await socket.write(`${encodeBase64(config.username)}\r
10883
+ await readResponse(["334"]);
10884
+ await write(`${encodeBase64(config.username)}\r
10757
10885
  `);
10758
- await waitForSmtpResponse(readResponse, ["334"]);
10759
- await socket.write(`${encodeBase64(config.password)}\r
10886
+ await readResponse(["334"]);
10887
+ await write(`${encodeBase64(config.password)}\r
10760
10888
  `);
10761
- await waitForSmtpResponse(readResponse, ["235"]);
10889
+ await readResponse(["235"]);
10762
10890
  }
10763
- const fromHeader = assertSafeSmtpAddress(config.from, "from");
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 waitForSmtpResponse(readResponse, ["250"]);
10770
- await socket.write(`RCPT TO:<${to}>\r
10893
+ await readResponse(["250"]);
10894
+ await write(`RCPT TO:<${toEnvelope}>\r
10771
10895
  `);
10772
- await waitForSmtpResponse(readResponse, ["250", "251"]);
10773
- await socket.write(`DATA\r
10896
+ await readResponse(["250", "251"]);
10897
+ await write(`DATA\r
10774
10898
  `);
10775
- await waitForSmtpResponse(readResponse, ["354"]);
10776
- const payload = buildSmtpPayload(fromHeader, {
10777
- ...message,
10778
- to: toHeader,
10779
- subject: assertSafeSmtpField(message.subject, "subject")
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
- await waitForSmtpResponse(readResponse, ["221"]);
10906
+ } catch {}
10786
10907
  } finally {
10787
- socket.end();
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: ${safeFrom}`,
10796
- `To: ${safeTo}`,
10797
- `Subject: ${safeSubject}`,
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
- const boundary = `strata-${Date.now().toString(36)}`;
10802
- headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
10803
- const parts = [
10804
- `--${boundary}`,
10805
- "Content-Type: text/plain; charset=utf-8",
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
- "Content-Type: text/html; charset=utf-8",
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
- return [...headers, "", ...parts, ".", ""].join(`\r
10816
- `);
10817
- }
10818
- headers.push("Content-Type: text/plain; charset=utf-8");
10819
- return [...headers, "", message.body, ".", ""].join(`\r
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.21",
3
+ "version": "2.2.23",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",