@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/dist/index.js CHANGED
@@ -10641,8 +10641,8 @@ function resolveSmtpConfig() {
10641
10641
  if (!from) {
10642
10642
  throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
10643
10643
  }
10644
- const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
10645
- if (!Number.isInteger(port) || port <= 0) {
10644
+ const port = Number(process.env.MAIL_PORT ?? "587");
10645
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
10646
10646
  throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
10647
10647
  }
10648
10648
  return {
@@ -10650,6 +10650,7 @@ function resolveSmtpConfig() {
10650
10650
  port,
10651
10651
  from,
10652
10652
  secure: (process.env.MAIL_SECURE ?? "false") === "true",
10653
+ ...process.env.MAIL_TIMEOUT_MS !== undefined ? { timeoutMs: smtpTimeout(Number(process.env.MAIL_TIMEOUT_MS)) } : {},
10653
10654
  ...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
10654
10655
  ...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
10655
10656
  };
@@ -10659,6 +10660,12 @@ function assertSafeSmtpField(value, field) {
10659
10660
  if (/[\r\n]/u.test(value)) {
10660
10661
  throw new Error(`Mail ${field} must not contain CR or LF.`);
10661
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
+ }
10662
10669
  return value;
10663
10670
  }
10664
10671
  function smtpEnvelopeAddress(value, field) {
@@ -10679,148 +10686,307 @@ function redactMailBody(body) {
10679
10686
  function encodeBase64(value) {
10680
10687
  return Buffer.from(value, "utf8").toString("base64");
10681
10688
  }
10682
- function parseSmtpResponses(buffer) {
10683
- const responses = [];
10684
- let remainder = buffer;
10685
- while (remainder.includes(`\r
10686
- `)) {
10687
- const index = remainder.indexOf(`\r
10688
- `);
10689
- const line = remainder.slice(0, index);
10690
- remainder = remainder.slice(index + 2);
10691
- if (line.length >= 4 && line[3] === "-") {
10692
- continue;
10693
- }
10694
- 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.");
10695
10692
  }
10696
- return { responses, remainder };
10693
+ return value;
10697
10694
  }
10698
- async function waitForSmtpResponse(readResponse, expectedCodes) {
10699
- const response = await readResponse();
10700
- const code = response.slice(0, 3);
10701
- if (!expectedCodes.includes(code)) {
10702
- 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.");
10703
10699
  }
10704
- return response;
10705
- }
10706
- async function openSmtpConnection(config) {
10707
- 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 = [];
10708
10732
  const waiters = [];
10709
- const readResponse = () => new Promise((resolve, reject) => {
10710
- const parsed = parseSmtpResponses(buffer);
10711
- if (parsed.responses.length > 0) {
10712
- buffer = parsed.remainder;
10713
- 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)
10714
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
+ }
10715
10786
  }
10716
- waiters.push({ resolve, reject });
10717
- });
10718
- const socket = await Bun.connect({
10719
- hostname: config.host,
10720
- port: config.port,
10721
- socket: {
10722
- open() {},
10723
- data(_socket, chunk) {
10724
- buffer += Buffer.from(chunk).toString("utf8");
10725
- const parsed = parseSmtpResponses(buffer);
10726
- buffer = parsed.remainder;
10727
- while (parsed.responses.length > 0 && waiters.length > 0) {
10728
- const response = parsed.responses.shift();
10729
- 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
10730
10797
  }
10731
- },
10732
- error(_socket, error) {
10733
- const pending = waiters.splice(0);
10734
- for (const waiter of pending) {
10735
- 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."));
10736
10867
  }
10737
10868
  }
10738
- }
10739
- });
10740
- return { socket, readResponse };
10741
- }
10742
- async function defaultSmtpTransport(config, message) {
10743
- if (config.username) {
10744
- assertSafeSmtpField(config.username, "username");
10745
- }
10746
- if (config.password) {
10747
- assertSafeSmtpField(config.password, "password");
10748
- }
10749
- const { socket, readResponse } = await openSmtpConnection(config);
10750
- try {
10751
- await waitForSmtpResponse(readResponse, ["220"]);
10752
- 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
10753
10878
  `);
10754
- await waitForSmtpResponse(readResponse, ["250"]);
10879
+ await readResponse(["250"]);
10755
10880
  if (config.username && config.password) {
10756
- await socket.write(`AUTH LOGIN\r
10881
+ await write(`AUTH LOGIN\r
10757
10882
  `);
10758
- await waitForSmtpResponse(readResponse, ["334"]);
10759
- await socket.write(`${encodeBase64(config.username)}\r
10883
+ await readResponse(["334"]);
10884
+ await write(`${encodeBase64(config.username)}\r
10760
10885
  `);
10761
- await waitForSmtpResponse(readResponse, ["334"]);
10762
- await socket.write(`${encodeBase64(config.password)}\r
10886
+ await readResponse(["334"]);
10887
+ await write(`${encodeBase64(config.password)}\r
10763
10888
  `);
10764
- await waitForSmtpResponse(readResponse, ["235"]);
10889
+ await readResponse(["235"]);
10765
10890
  }
10766
- const fromHeader = assertSafeSmtpAddress(config.from, "from");
10767
- const toHeader = assertSafeSmtpAddress(message.to, "to");
10768
- const from = smtpEnvelopeAddress(config.from, "from");
10769
- const to = smtpEnvelopeAddress(message.to, "to");
10770
- await socket.write(`MAIL FROM:<${from}>\r
10891
+ await write(`MAIL FROM:<${fromEnvelope}>\r
10771
10892
  `);
10772
- await waitForSmtpResponse(readResponse, ["250"]);
10773
- await socket.write(`RCPT TO:<${to}>\r
10893
+ await readResponse(["250"]);
10894
+ await write(`RCPT TO:<${toEnvelope}>\r
10774
10895
  `);
10775
- await waitForSmtpResponse(readResponse, ["250", "251"]);
10776
- await socket.write(`DATA\r
10896
+ await readResponse(["250", "251"]);
10897
+ await write(`DATA\r
10777
10898
  `);
10778
- await waitForSmtpResponse(readResponse, ["354"]);
10779
- const payload = buildSmtpPayload(fromHeader, {
10780
- ...message,
10781
- to: toHeader,
10782
- subject: assertSafeSmtpField(message.subject, "subject")
10783
- });
10784
- await socket.write(payload);
10785
- await waitForSmtpResponse(readResponse, ["250"]);
10786
- 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
10787
10905
  `);
10788
- await waitForSmtpResponse(readResponse, ["221"]);
10906
+ } catch {}
10789
10907
  } finally {
10790
- 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();
10791
10915
  }
10792
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
+ }
10793
10958
  function buildSmtpPayload(from, message) {
10794
- const safeFrom = assertSafeSmtpAddress(from, "from");
10795
- const safeTo = assertSafeSmtpAddress(message.to, "to");
10796
- const safeSubject = assertSafeSmtpField(message.subject, "subject");
10797
10959
  const headers = [
10798
- `From: ${safeFrom}`,
10799
- `To: ${safeTo}`,
10800
- `Subject: ${safeSubject}`,
10960
+ `From: ${smtpAddressHeader(assertSafeSmtpAddress(from, "from"))}`,
10961
+ `To: ${smtpAddressHeader(assertSafeSmtpAddress(message.to, "to"))}`,
10962
+ `Subject: ${encodeSmtpHeader(assertSafeSmtpField(message.subject, "subject"))}`,
10801
10963
  "MIME-Version: 1.0"
10802
10964
  ];
10965
+ let lines;
10803
10966
  if (message.html) {
10804
- const boundary = `strata-${Date.now().toString(36)}`;
10805
- headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
10806
- const parts = [
10807
- `--${boundary}`,
10808
- "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}"`,
10809
10974
  "",
10810
- message.body,
10811
10975
  `--${boundary}`,
10812
- "Content-Type: text/html; charset=utf-8",
10813
- "",
10814
- message.html,
10976
+ ...smtpMimePart("text/plain", message.body),
10977
+ `--${boundary}`,
10978
+ ...smtpMimePart("text/html", message.html),
10815
10979
  `--${boundary}--`,
10816
10980
  ""
10817
10981
  ];
10818
- return [...headers, "", ...parts, ".", ""].join(`\r
10819
- `);
10820
- }
10821
- headers.push("Content-Type: text/plain; charset=utf-8");
10822
- return [...headers, "", message.body, ".", ""].join(`\r
10823
- `);
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
+ `;
10824
10990
  }
10825
10991
 
10826
10992
  class LogMailDriver {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.22",
3
+ "version": "2.2.23",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",