@upyo/smtp 0.6.0-dev.309 → 0.6.0-dev.312

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.cjs CHANGED
@@ -25,6 +25,7 @@ const __upyo_core = __toESM(require("@upyo/core"));
25
25
  const node_buffer = __toESM(require("node:buffer"));
26
26
  const node_net = __toESM(require("node:net"));
27
27
  const node_tls = __toESM(require("node:tls"));
28
+ const node_crypto = __toESM(require("node:crypto"));
28
29
 
29
30
  //#region src/delivery-status.ts
30
31
  /**
@@ -134,6 +135,40 @@ function assertParameterLength(parameter, maximum, name) {
134
135
  if (parameter.length > maximum) throw new SmtpDsnValidationError(`${name} parameter exceeds the RFC 3461 limit of ${maximum} characters.`);
135
136
  }
136
137
 
138
+ //#endregion
139
+ //#region src/dkim/types.ts
140
+ /**
141
+ * Validates the runtime body-mode option without changing config identity.
142
+ * @param config Optional signing configuration.
143
+ * @throws {TypeError} If the body mode is unsupported.
144
+ */
145
+ function validateDkimBodyMode(config) {
146
+ if (config?.bodyMode !== void 0 && config.bodyMode !== "buffered" && config.bodyMode !== "streaming") throw new TypeError("Expected DKIM bodyMode to be buffered or streaming.");
147
+ }
148
+ /**
149
+ * Default header fields to sign if not specified.
150
+ *
151
+ * @since 0.4.0
152
+ */
153
+ const DEFAULT_SIGNED_HEADERS = [
154
+ "from",
155
+ "to",
156
+ "subject",
157
+ "date"
158
+ ];
159
+ /**
160
+ * Default DKIM algorithm.
161
+ *
162
+ * @since 0.4.0
163
+ */
164
+ const DEFAULT_ALGORITHM = "rsa-sha256";
165
+ /**
166
+ * Default canonicalization method.
167
+ *
168
+ * @since 0.4.0
169
+ */
170
+ const DEFAULT_CANONICALIZATION = "relaxed/relaxed";
171
+
137
172
  //#endregion
138
173
  //#region src/config.ts
139
174
  /**
@@ -148,6 +183,7 @@ function assertParameterLength(parameter, maximum, name) {
148
183
  * @internal
149
184
  */
150
185
  function createSmtpConfig(config) {
186
+ validateDkimBodyMode(config.dkim);
151
187
  const port = config.port ?? 587;
152
188
  return {
153
189
  host: config.host,
@@ -327,7 +363,7 @@ function truncateErrorBody(text) {
327
363
  * @returns A promise that settles with `promise`, or rejects when `signal`
328
364
  * aborts.
329
365
  */
330
- function abortable(promise, signal) {
366
+ function abortable$1(promise, signal) {
331
367
  if (signal == null) return promise;
332
368
  return new Promise((resolve, reject) => {
333
369
  const abortReason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
@@ -401,7 +437,7 @@ var OAuth2TokenManager = class {
401
437
  }).finally(() => {
402
438
  this.pending = void 0;
403
439
  });
404
- const { accessToken } = await abortable(this.pending, signal);
440
+ const { accessToken } = await abortable$1(this.pending, signal);
405
441
  return accessToken;
406
442
  }
407
443
  /**
@@ -469,6 +505,541 @@ var OAuth2TokenManager = class {
469
505
  }
470
506
  };
471
507
 
508
+ //#endregion
509
+ //#region src/dkim/body-hash.ts
510
+ /** Incremental RFC 6376 body hash with bounded whitespace and line state. */
511
+ var BodyHasher = class {
512
+ hash = (0, node_crypto.createHash)("sha256");
513
+ buffer = new Uint8Array(65536);
514
+ used = 0;
515
+ cr = false;
516
+ whitespace = false;
517
+ newlines = 0;
518
+ nonempty = false;
519
+ constructor(mode) {
520
+ this.mode = mode;
521
+ }
522
+ update(bytes) {
523
+ for (const byte of bytes) {
524
+ if (this.cr) {
525
+ this.cr = false;
526
+ if (byte === 10) {
527
+ this.whitespace = false;
528
+ this.newlines++;
529
+ continue;
530
+ }
531
+ this.content(13);
532
+ }
533
+ if (byte === 13) this.cr = true;
534
+ else this.content(byte);
535
+ }
536
+ }
537
+ content(byte) {
538
+ if (this.mode === "relaxed" && (byte === 32 || byte === 9)) {
539
+ this.whitespace = true;
540
+ return;
541
+ }
542
+ while (this.newlines > 0) {
543
+ this.emit(13);
544
+ this.emit(10);
545
+ this.newlines--;
546
+ }
547
+ if (this.whitespace) {
548
+ this.emit(32);
549
+ this.whitespace = false;
550
+ }
551
+ this.emit(byte);
552
+ this.nonempty = true;
553
+ }
554
+ emit(byte) {
555
+ this.buffer[this.used++] = byte;
556
+ if (this.used === this.buffer.length) {
557
+ this.hash.update(this.buffer);
558
+ this.used = 0;
559
+ }
560
+ }
561
+ digest() {
562
+ if (this.cr) this.content(13);
563
+ if (this.nonempty || this.mode === "simple") {
564
+ this.emit(13);
565
+ this.emit(10);
566
+ }
567
+ this.hash.update(this.buffer.subarray(0, this.used));
568
+ return this.hash.digest("base64");
569
+ }
570
+ };
571
+
572
+ //#endregion
573
+ //#region src/dkim/canonicalize.ts
574
+ /**
575
+ * DKIM Canonicalization algorithms per RFC 6376 Section 3.4.
576
+ *
577
+ * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4
578
+ * @since 0.4.0
579
+ */
580
+ /**
581
+ * Simple header canonicalization.
582
+ *
583
+ * The "simple" header canonicalization algorithm does not change header
584
+ * fields in any way. Header fields are presented to the signing or
585
+ * verification algorithm exactly as they are in the message.
586
+ *
587
+ * @param name - The header field name
588
+ * @param value - The header field value
589
+ * @returns The canonicalized header line (name:value)
590
+ * @see RFC 6376 Section 3.4.1
591
+ * @since 0.4.0
592
+ */
593
+ function canonicalizeHeaderSimple(name, value) {
594
+ return `${name}:${value}`;
595
+ }
596
+ /**
597
+ * Relaxed header canonicalization.
598
+ *
599
+ * The "relaxed" header canonicalization algorithm:
600
+ * - Convert header field names to lowercase
601
+ * - Unfold header field continuation lines
602
+ * - Collapse whitespace sequences to a single space
603
+ * - Remove leading and trailing whitespace from header field values
604
+ *
605
+ * @param name - The header field name
606
+ * @param value - The header field value
607
+ * @returns The canonicalized header line (name:value)
608
+ * @see RFC 6376 Section 3.4.2
609
+ * @since 0.4.0
610
+ */
611
+ function canonicalizeHeaderRelaxed(name, value) {
612
+ const canonicalName = name.toLowerCase();
613
+ const canonicalValue = value.replace(/\r\n[\t ]+/g, " ").replace(/[\t ]+/g, " ").trim();
614
+ return `${canonicalName}:${canonicalValue}`;
615
+ }
616
+
617
+ //#endregion
618
+ //#region src/dkim/sign.ts
619
+ /**
620
+ * Signs frozen headers using a body hash computed by the MIME reader.
621
+ * @param rawHeaders Frozen wire headers, including earlier signatures.
622
+ * @param config Signature configuration.
623
+ * @param bodyHash Canonical body SHA-256 digest, encoded as base64.
624
+ * @param signal Optional cancellation signal.
625
+ * @returns A DKIM-Signature header value.
626
+ * @throws {Error} If key import, signing, or cancellation fails.
627
+ */
628
+ async function signWithBodyHash(rawHeaders, config, bodyHash, signal) {
629
+ signal?.throwIfAborted();
630
+ const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
631
+ const canonicalization = config.canonicalization ?? DEFAULT_CANONICALIZATION;
632
+ const headerFields = config.headerFields ?? DEFAULT_SIGNED_HEADERS;
633
+ const { headers } = parseMessage(rawHeaders);
634
+ const [headerCanon] = canonicalization.split("/");
635
+ const privateKey = await getPrivateKey(config.privateKey, algorithm);
636
+ signal?.throwIfAborted();
637
+ const dkimHeaderValue = buildDkimHeaderValue({
638
+ algorithm,
639
+ canonicalization,
640
+ signingDomain: config.signingDomain,
641
+ selector: config.selector,
642
+ headerFields,
643
+ bodyHash
644
+ });
645
+ const signatureData = buildSignatureData(headers, headerFields, headerCanon, dkimHeaderValue);
646
+ const signature = await signData(signatureData, privateKey, algorithm);
647
+ signal?.throwIfAborted();
648
+ return {
649
+ headerName: "DKIM-Signature",
650
+ signature: `${dkimHeaderValue} b=${signature}`
651
+ };
652
+ }
653
+ /**
654
+ * Parses a raw email message into headers and body.
655
+ */
656
+ function parseMessage(rawMessage) {
657
+ const separatorIndex = rawMessage.indexOf("\r\n\r\n");
658
+ if (separatorIndex === -1) return {
659
+ headers: parseHeaders(rawMessage),
660
+ body: ""
661
+ };
662
+ const headerSection = rawMessage.substring(0, separatorIndex);
663
+ const body = rawMessage.substring(separatorIndex + 4);
664
+ return {
665
+ headers: parseHeaders(headerSection),
666
+ body
667
+ };
668
+ }
669
+ /**
670
+ * Parses header section into a map of header name to value.
671
+ * Handles folded headers (continuation lines).
672
+ */
673
+ function parseHeaders(headerSection) {
674
+ const headers = /* @__PURE__ */ new Map();
675
+ const lines = headerSection.split("\r\n");
676
+ let currentName = "";
677
+ let currentValue = "";
678
+ for (const line of lines) if (line.startsWith(" ") || line.startsWith(" ")) currentValue += "\r\n" + line;
679
+ else {
680
+ if (currentName) headers.set(currentName.toLowerCase(), {
681
+ name: currentName,
682
+ value: currentValue
683
+ });
684
+ const colonIndex = line.indexOf(":");
685
+ if (colonIndex > 0) {
686
+ currentName = line.substring(0, colonIndex);
687
+ currentValue = line.substring(colonIndex + 1);
688
+ }
689
+ }
690
+ if (currentName) headers.set(currentName.toLowerCase(), {
691
+ name: currentName,
692
+ value: currentValue
693
+ });
694
+ return headers;
695
+ }
696
+ /**
697
+ * Gets the private key, either using a provided CryptoKey or importing from PEM.
698
+ */
699
+ function getPrivateKey(key, algorithm) {
700
+ if (typeof key !== "string") return key;
701
+ return importPrivateKey(key, algorithm);
702
+ }
703
+ /**
704
+ * Imports a PEM-encoded private key for use with Web Crypto API.
705
+ */
706
+ async function importPrivateKey(pem, algorithm) {
707
+ try {
708
+ const pemContents = pem.replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/, "").replace(/-----END (?:RSA )?PRIVATE KEY-----/, "").replace(/\s/g, "");
709
+ const binaryString = atob(pemContents);
710
+ const bytes = new Uint8Array(binaryString.length);
711
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
712
+ const keyAlgorithm = algorithm === "ed25519-sha256" ? { name: "Ed25519" } : {
713
+ name: "RSASSA-PKCS1-v1_5",
714
+ hash: "SHA-256"
715
+ };
716
+ return await crypto.subtle.importKey("pkcs8", bytes, keyAlgorithm, false, ["sign"]);
717
+ } catch (error) {
718
+ throw new Error(`Failed to import private key: ${error instanceof Error ? error.message : String(error)}`);
719
+ }
720
+ }
721
+ /**
722
+ * Builds the DKIM-Signature header value without the b= signature.
723
+ */
724
+ function buildDkimHeaderValue(params) {
725
+ const parts = [
726
+ "v=1",
727
+ `a=${params.algorithm}`,
728
+ `c=${params.canonicalization}`,
729
+ `d=${params.signingDomain}`,
730
+ `s=${params.selector}`,
731
+ `h=${params.headerFields.join(":")}`,
732
+ `bh=${params.bodyHash};`
733
+ ];
734
+ return parts.join("; ");
735
+ }
736
+ /**
737
+ * Builds the data to be signed (canonicalized headers + DKIM-Signature header).
738
+ */
739
+ function buildSignatureData(headers, headerFields, canonMethod, dkimHeaderValue) {
740
+ const lines = [];
741
+ for (const field of headerFields) {
742
+ const header = headers.get(field.toLowerCase());
743
+ if (header !== void 0) {
744
+ const canonicalized = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed(header.name, header.value) : canonicalizeHeaderSimple(header.name, header.value);
745
+ lines.push(canonicalized);
746
+ }
747
+ }
748
+ const dkimHeader = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed("DKIM-Signature", " " + dkimHeaderValue + " b=") : canonicalizeHeaderSimple("DKIM-Signature", " " + dkimHeaderValue + " b=");
749
+ lines.push(dkimHeader);
750
+ return lines.join("\r\n");
751
+ }
752
+ /**
753
+ * Signs data using the appropriate algorithm.
754
+ */
755
+ async function signData(data, privateKey, algorithm) {
756
+ const encoder = new TextEncoder();
757
+ const dataBuffer = encoder.encode(data);
758
+ const signAlgorithm = algorithm === "ed25519-sha256" ? "Ed25519" : "RSASSA-PKCS1-v1_5";
759
+ const signingInput = algorithm === "ed25519-sha256" ? await crypto.subtle.digest("SHA-256", dataBuffer) : dataBuffer;
760
+ const signature = await crypto.subtle.sign(signAlgorithm, privateKey, signingInput);
761
+ return arrayBufferToBase64(signature);
762
+ }
763
+ /**
764
+ * Converts an ArrayBuffer to a Base64 string.
765
+ */
766
+ function arrayBufferToBase64(buffer) {
767
+ const bytes = new Uint8Array(buffer);
768
+ let binary = "";
769
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
770
+ return btoa(binary);
771
+ }
772
+
773
+ //#endregion
774
+ //#region src/message-stream.ts
775
+ /**
776
+ * A replayable attachment changed between DKIM body reads.
777
+ * @since 0.6.0
778
+ */
779
+ var SmtpAttachmentReplayError = class extends TypeError {
780
+ /** Creates a replay validation failure. */
781
+ constructor() {
782
+ super("Attachment content changed between DKIM body reads.");
783
+ this.name = "SmtpAttachmentReplayError";
784
+ }
785
+ };
786
+ /** Prepares signatures and sizes without consuming unsigned factory sources. */
787
+ async function prepareMessageStream(plan, checkSize, progress, signal) {
788
+ const knownSize = await plan.size(signal, checkSize);
789
+ if (knownSize != null) checkSize(knownSize);
790
+ const signatures = plan.dkim?.signatures ?? [];
791
+ if (signatures.length === 0) return {
792
+ size: knownSize,
793
+ async *read(signal$1, progress$1) {
794
+ yield node_buffer.Buffer.from(plan.headers);
795
+ yield* plan.body(signal$1, progress$1);
796
+ }
797
+ };
798
+ const buffered = plan.dkim?.bodyMode !== "streaming";
799
+ const chunks = [];
800
+ const hashes = /* @__PURE__ */ new Map();
801
+ for (const sig of signatures) {
802
+ const mode = sig.canonicalization?.endsWith("/simple") ? "simple" : "relaxed";
803
+ if (!hashes.has(mode)) hashes.set(mode, new BodyHasher(mode));
804
+ }
805
+ const rawHash = (0, node_crypto.createHash)("sha256");
806
+ let length = 0;
807
+ const headerLength = node_buffer.Buffer.byteLength(plan.headers);
808
+ for await (const chunk of plan.body(signal, progress)) {
809
+ length += chunk.length;
810
+ checkSize(headerLength + length);
811
+ if (chunk.length > 0) progress();
812
+ if (buffered) chunks.push(chunk.slice());
813
+ rawHash.update(chunk);
814
+ for (const hash of hashes.values()) hash.update(chunk);
815
+ }
816
+ const expectedDigest = rawHash.digest("hex");
817
+ const bodyHashes = new Map(Array.from(hashes, ([mode, hash]) => [mode, hash.digest()]));
818
+ let headers = plan.headers;
819
+ try {
820
+ for (const sig of signatures) {
821
+ const mode = sig.canonicalization?.endsWith("/simple") ? "simple" : "relaxed";
822
+ const result = await signWithBodyHash(headers, sig, bodyHashes.get(mode), signal);
823
+ headers = `${result.headerName}: ${result.signature}\r\n${headers}`;
824
+ }
825
+ } catch (error) {
826
+ signal?.throwIfAborted();
827
+ if (plan.dkim?.onSigningFailure !== "send-unsigned") throw error;
828
+ console.warn("DKIM signing failed, sending unsigned:", error);
829
+ }
830
+ const size = node_buffer.Buffer.byteLength(headers) + length;
831
+ checkSize(size);
832
+ return {
833
+ size,
834
+ async *read(signal$1, progress$1) {
835
+ yield node_buffer.Buffer.from(headers);
836
+ if (buffered) {
837
+ for (const chunk of chunks) {
838
+ signal$1?.throwIfAborted();
839
+ yield chunk;
840
+ }
841
+ return;
842
+ }
843
+ const replayHash = (0, node_crypto.createHash)("sha256");
844
+ let replayLength = 0;
845
+ for await (const chunk of plan.body(signal$1, progress$1)) {
846
+ replayLength += chunk.length;
847
+ if (replayLength > length) throw new SmtpAttachmentReplayError();
848
+ replayHash.update(chunk);
849
+ yield chunk;
850
+ }
851
+ if (replayLength !== length || replayHash.digest("hex") !== expectedDigest) throw new SmtpAttachmentReplayError();
852
+ }
853
+ };
854
+ }
855
+
856
+ //#endregion
857
+ //#region src/data-stream.ts
858
+ /** Races preparation against source inactivity and remote socket termination. */
859
+ async function prepareOnSocket(socket, timeoutMs, prepare, signal) {
860
+ const controller = new AbortController();
861
+ const combined = (0, __upyo_core.combineSignals)(controller.signal, signal);
862
+ const fail = (error) => controller.abort(error);
863
+ const closed = () => fail(/* @__PURE__ */ new TypeError("SMTP connection closed during message preparation."));
864
+ const data = () => fail(/* @__PURE__ */ new TypeError("Unexpected SMTP reply during message preparation."));
865
+ let timer;
866
+ const progress = () => {
867
+ clearTimeout(timer);
868
+ timer = setTimeout(() => fail(/* @__PURE__ */ new TypeError("SMTP message preparation timeout.")), timeoutMs);
869
+ };
870
+ socket.on("error", fail);
871
+ socket.on("close", closed);
872
+ socket.on("data", data);
873
+ progress();
874
+ try {
875
+ if (socket.destroyed || !socket.writable) closed();
876
+ combined.signal.throwIfAborted();
877
+ return await abortable(prepare(combined.signal, progress), combined.signal);
878
+ } catch (error) {
879
+ const interrupted = combined.signal.aborted;
880
+ controller.abort(error);
881
+ if (interrupted) socket.destroy();
882
+ signal?.throwIfAborted();
883
+ throw error;
884
+ } finally {
885
+ clearTimeout(timer);
886
+ socket.off("error", fail);
887
+ socket.off("close", closed);
888
+ socket.off("data", data);
889
+ combined.cleanup();
890
+ }
891
+ }
892
+ function abortable(promise, signal) {
893
+ return new Promise((resolve, reject) => {
894
+ const abort = () => reject(signal.reason);
895
+ signal.addEventListener("abort", abort, { once: true });
896
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
897
+ if (signal.aborted) abort();
898
+ });
899
+ }
900
+ /** Writes one DATA body with bounded transparency buffers and backpressure. */
901
+ async function writeMessageData(socket, source, timeoutMs, checkSize, signal) {
902
+ const controller = new AbortController();
903
+ const combined = (0, __upyo_core.combineSignals)(controller.signal, signal);
904
+ const owned = combined.signal;
905
+ let terminated = false;
906
+ let complete = false;
907
+ let replyBytes = 0;
908
+ let buffer = "";
909
+ const lines = [];
910
+ let resolveReply;
911
+ const reply = new Promise((resolve) => resolveReply = resolve);
912
+ const fail = (error) => {
913
+ if (!owned.aborted) controller.abort(error);
914
+ socket.destroy();
915
+ };
916
+ const close = () => fail(/* @__PURE__ */ new TypeError("SMTP connection closed during DATA."));
917
+ const onAbort = () => socket.destroy();
918
+ let timer;
919
+ const progress = () => {
920
+ clearTimeout(timer);
921
+ timer = setTimeout(() => fail(/* @__PURE__ */ new TypeError("SMTP DATA timeout.")), timeoutMs);
922
+ };
923
+ const data = (chunk) => {
924
+ replyBytes += chunk.length;
925
+ if (replyBytes > 65536) {
926
+ fail(/* @__PURE__ */ new RangeError("SMTP DATA reply exceeds 64 KiB."));
927
+ return;
928
+ }
929
+ buffer += node_buffer.Buffer.from(chunk).toString("utf8");
930
+ let end;
931
+ while ((end = buffer.indexOf("\r\n")) >= 0) {
932
+ const line = buffer.slice(0, end);
933
+ buffer = buffer.slice(end + 2);
934
+ lines.push(line);
935
+ if (/^\d{3} /.test(line)) {
936
+ if (!terminated) fail(/* @__PURE__ */ new TypeError(`Premature SMTP DATA reply: ${line}`));
937
+ else resolveReply({
938
+ code: Number(line.slice(0, 3)),
939
+ message: line.slice(4),
940
+ raw: lines.join("\r\n")
941
+ });
942
+ return;
943
+ }
944
+ }
945
+ };
946
+ socket.on("error", fail);
947
+ socket.on("close", close);
948
+ socket.on("data", data);
949
+ owned.addEventListener("abort", onAbort, { once: true });
950
+ progress();
951
+ let iterator;
952
+ async function write(bytes) {
953
+ owned.throwIfAborted();
954
+ await abortable(new Promise((resolve, reject) => {
955
+ let written = false;
956
+ let drained = false;
957
+ let returned = false;
958
+ const cleanup = () => {
959
+ socket.off("drain", drain);
960
+ owned.removeEventListener("abort", cleanup);
961
+ };
962
+ const finish = () => {
963
+ if (returned && written && drained) {
964
+ cleanup();
965
+ progress();
966
+ resolve();
967
+ }
968
+ };
969
+ const drain = () => {
970
+ drained = true;
971
+ finish();
972
+ };
973
+ socket.once("drain", drain);
974
+ owned.addEventListener("abort", cleanup, { once: true });
975
+ try {
976
+ const accepted = socket.write(bytes, (error) => {
977
+ if (error != null) {
978
+ cleanup();
979
+ reject(error);
980
+ return;
981
+ }
982
+ written = true;
983
+ finish();
984
+ });
985
+ drained = accepted || drained;
986
+ returned = true;
987
+ finish();
988
+ } catch (error) {
989
+ cleanup();
990
+ reject(error);
991
+ }
992
+ }), owned);
993
+ }
994
+ try {
995
+ iterator = source(owned, progress)[Symbol.asyncIterator]();
996
+ if (socket.destroyed || !socket.writable) close();
997
+ let lineStart = true;
998
+ let size = 0;
999
+ while (true) {
1000
+ owned.throwIfAborted();
1001
+ const item = await abortable(Promise.resolve(iterator.next()), owned);
1002
+ if (item.done) break;
1003
+ const chunk = item.value;
1004
+ for (let offset = 0; offset < chunk.length; offset += 32768) {
1005
+ const window = chunk.subarray(offset, offset + 32768);
1006
+ size += window.length;
1007
+ checkSize(size);
1008
+ progress();
1009
+ const output = node_buffer.Buffer.allocUnsafe(window.length * 2);
1010
+ let length = 0;
1011
+ for (const byte of window) {
1012
+ if (lineStart && byte === 46) output[length++] = 46;
1013
+ output[length++] = byte;
1014
+ lineStart = byte === 10;
1015
+ }
1016
+ await write(output.subarray(0, length));
1017
+ }
1018
+ }
1019
+ owned.throwIfAborted();
1020
+ terminated = true;
1021
+ await write(node_buffer.Buffer.from(".\r\n"));
1022
+ const result = await abortable(reply, owned);
1023
+ owned.throwIfAborted();
1024
+ complete = true;
1025
+ return result;
1026
+ } catch (error) {
1027
+ fail(error);
1028
+ signal?.throwIfAborted();
1029
+ throw owned.reason;
1030
+ } finally {
1031
+ if (!complete) try {
1032
+ Promise.resolve(iterator?.return?.()).catch(() => {});
1033
+ } catch {}
1034
+ clearTimeout(timer);
1035
+ socket.off("error", fail);
1036
+ socket.off("close", close);
1037
+ socket.off("data", data);
1038
+ owned.removeEventListener("abort", onAbort);
1039
+ combined.cleanup();
1040
+ }
1041
+ }
1042
+
472
1043
  //#endregion
473
1044
  //#region src/smtp-status-code.ts
474
1045
  /**
@@ -503,13 +1074,13 @@ function parseEnhancedSmtpStatusCode(replyCode, response) {
503
1074
  * Error thrown when a message exceeds the fixed limit advertised through the
504
1075
  * SMTP SIZE extension.
505
1076
  *
506
- * The check happens before `MAIL FROM`, so the SMTP connection remains usable
507
- * for another message.
1077
+ * Known sizes are checked before `MAIL FROM`. Unknown source sizes are also
1078
+ * checked while writing DATA; those failures make the connection unusable.
508
1079
  *
509
1080
  * @since 0.6.0
510
1081
  */
511
1082
  var SmtpMessageSizeError = class extends RangeError {
512
- /** The encoded message size in octets. */
1083
+ /** Exact known size, or the octets counted when an unknown source is stopped. */
513
1084
  actualSize;
514
1085
  /** The fixed maximum advertised by the SMTP server. */
515
1086
  maximumSize;
@@ -518,9 +1089,11 @@ var SmtpMessageSizeError = class extends RangeError {
518
1089
  *
519
1090
  * @param actualSize The encoded message size in octets.
520
1091
  * @param maximumSize The fixed maximum advertised by the SMTP server.
1092
+ * @param phase Whether the failure occurred before or during DATA.
521
1093
  */
522
- constructor(actualSize, maximumSize) {
1094
+ constructor(actualSize, maximumSize, phase = "preflight") {
523
1095
  super(`Message size ${actualSize} octets exceeds the server's maximum of ${maximumSize} octets.`);
1096
+ this.phase = phase;
524
1097
  this.name = "SmtpMessageSizeError";
525
1098
  this.actualSize = actualSize;
526
1099
  this.maximumSize = maximumSize;
@@ -715,6 +1288,16 @@ var SmtpConnection = class {
715
1288
  authenticated = false;
716
1289
  capabilities = [];
717
1290
  tokenManager;
1291
+ active = false;
1292
+ get usable() {
1293
+ return this.socket != null && !this.socket.destroyed && this.socket.writable;
1294
+ }
1295
+ observeSocket(socket) {
1296
+ socket.on("error", () => socket.destroy());
1297
+ socket.on("timeout", () => {
1298
+ if (!this.active) socket.destroy();
1299
+ });
1300
+ }
718
1301
  constructor(config, tokenManager) {
719
1302
  this.config = createSmtpConfig(config);
720
1303
  this.tokenManager = tokenManager ?? null;
@@ -724,17 +1307,31 @@ var SmtpConnection = class {
724
1307
  signal?.throwIfAborted();
725
1308
  return new Promise((resolve, reject) => {
726
1309
  const timeout = setTimeout(() => {
1310
+ onError(/* @__PURE__ */ new TypeError("Connection timeout."));
727
1311
  this.socket?.destroy();
728
- reject(/* @__PURE__ */ new Error("Connection timeout"));
729
1312
  }, this.config.connectionTimeout);
730
- const onConnect = () => {
1313
+ const cleanup = () => {
731
1314
  clearTimeout(timeout);
1315
+ this.socket?.off("connect", onConnect);
1316
+ this.socket?.off("error", onError);
1317
+ this.socket?.off("close", onClose);
1318
+ this.socket?.off("timeout", onTimeout);
1319
+ };
1320
+ const onConnect = () => {
1321
+ cleanup();
732
1322
  resolve();
733
1323
  };
734
1324
  const onError = (error) => {
735
- clearTimeout(timeout);
1325
+ cleanup();
736
1326
  reject(error);
737
1327
  };
1328
+ const onClose = () => {
1329
+ onError(/* @__PURE__ */ new TypeError("SMTP connection closed before establishment."));
1330
+ };
1331
+ const onTimeout = () => {
1332
+ onError(/* @__PURE__ */ new TypeError("Socket timeout."));
1333
+ this.socket?.destroy();
1334
+ };
738
1335
  if (this.config.secure) this.socket = (0, node_tls.connect)({
739
1336
  host: this.config.host,
740
1337
  port: this.config.port,
@@ -750,13 +1347,11 @@ var SmtpConnection = class {
750
1347
  this.socket.connect(this.config.port, this.config.host);
751
1348
  }
752
1349
  this.socket.setTimeout(this.config.socketTimeout);
1350
+ this.observeSocket(this.socket);
753
1351
  this.socket.once("connect", onConnect);
754
1352
  this.socket.once("error", onError);
755
- this.socket.once("timeout", () => {
756
- clearTimeout(timeout);
757
- this.socket?.destroy();
758
- reject(/* @__PURE__ */ new Error("Socket timeout"));
759
- });
1353
+ this.socket.once("close", onClose);
1354
+ this.socket.once("timeout", onTimeout);
760
1355
  });
761
1356
  }
762
1357
  sendCommand(command, signal) {
@@ -789,7 +1384,7 @@ var SmtpConnection = class {
789
1384
  buffer += data.toString();
790
1385
  const lines = buffer.split("\r\n");
791
1386
  const incompleteLine = lines.pop() || "";
792
- for (const line of lines) {
1387
+ for (const [lineIndex, line] of lines.entries()) {
793
1388
  responseLines.push(line);
794
1389
  if (line.length >= 4 && line[3] === " ") {
795
1390
  const code = parseInt(line.substring(0, 3), 10);
@@ -804,6 +1399,11 @@ var SmtpConnection = class {
804
1399
  responseLines = [];
805
1400
  if (responses.length === commands.length) {
806
1401
  cleanup();
1402
+ if (commands[0] === "DATA" && (lineIndex + 1 < lines.length || incompleteLine.length > 0)) {
1403
+ this.socket?.destroy();
1404
+ reject(/* @__PURE__ */ new TypeError("Premature SMTP reply after DATA readiness."));
1405
+ return;
1406
+ }
807
1407
  resolve(responses);
808
1408
  return;
809
1409
  }
@@ -914,6 +1514,7 @@ var SmtpConnection = class {
914
1514
  reject(/* @__PURE__ */ new Error("STARTTLS upgrade timeout"));
915
1515
  }, this.config.connectionTimeout);
916
1516
  const plainSocket = this.socket;
1517
+ plainSocket.setTimeout(0);
917
1518
  const tlsSocket = (0, node_tls.connect)({
918
1519
  socket: plainSocket,
919
1520
  host: this.config.host,
@@ -926,7 +1527,9 @@ var SmtpConnection = class {
926
1527
  });
927
1528
  const onSecureConnect = () => {
928
1529
  clearTimeout(timeout);
1530
+ tlsSocket.off("error", onError);
929
1531
  this.socket = tlsSocket;
1532
+ this.observeSocket(tlsSocket);
930
1533
  this.socket.setTimeout(this.config.socketTimeout);
931
1534
  resolve();
932
1535
  };
@@ -937,11 +1540,6 @@ var SmtpConnection = class {
937
1540
  };
938
1541
  tlsSocket.once("secureConnect", onSecureConnect);
939
1542
  tlsSocket.once("error", onError);
940
- tlsSocket.once("timeout", () => {
941
- clearTimeout(timeout);
942
- tlsSocket.destroy();
943
- reject(/* @__PURE__ */ new Error("TLS upgrade timeout"));
944
- });
945
1543
  });
946
1544
  }
947
1545
  async authenticate(signal) {
@@ -1055,6 +1653,16 @@ var SmtpConnection = class {
1055
1653
  throw new SmtpAuthResponseError(`${mechanism} authentication failed: ${response.message}`, response.code, `AUTH ${mechanism}`, response.message);
1056
1654
  }
1057
1655
  async sendMessage(message, signal) {
1656
+ this.active = true;
1657
+ this.socket?.setTimeout(0);
1658
+ try {
1659
+ return await this.sendPreparedMessage(message, signal);
1660
+ } finally {
1661
+ this.active = false;
1662
+ if (this.usable) this.socket?.setTimeout(this.config.socketTimeout);
1663
+ }
1664
+ }
1665
+ async sendPreparedMessage(message, signal) {
1058
1666
  signal?.throwIfAborted();
1059
1667
  let smtpUtf8Parameters = "";
1060
1668
  if (message.requiresSmtpUtf8 === true) {
@@ -1064,14 +1672,25 @@ var SmtpConnection = class {
1064
1672
  }
1065
1673
  const sizeCapability = parseSizeCapability(this.capabilities);
1066
1674
  let sizeParameter = "";
1067
- if (sizeCapability != null) {
1068
- const messageSize = node_buffer.Buffer.byteLength(message.raw, "utf8") + CRLF_LENGTH;
1069
- if (sizeCapability.maximum != null && BigInt(messageSize) > sizeCapability.maximum) throw new SmtpMessageSizeError(messageSize, sizeCapability.maximum);
1070
- sizeParameter = ` SIZE=${messageSize}`;
1071
- }
1072
1675
  const dsn = message.envelope.dsn;
1073
1676
  if (dsn != null && !this.capabilities.some((capability) => /^DSN[ \t]*$/i.test(capability))) throw new SmtpDsnUnsupportedError();
1074
1677
  const mailDsnParameters = dsn == null || dsn.mailParameters.length === 0 ? "" : ` ${dsn.mailParameters.join(" ")}`;
1678
+ const checkSize = (size, phase = "preflight") => {
1679
+ if (!Number.isSafeInteger(size)) throw new RangeError("Message size exceeds the safe integer range.");
1680
+ if (sizeCapability?.maximum != null && BigInt(size) > sizeCapability.maximum) throw new SmtpMessageSizeError(size, sizeCapability.maximum, phase);
1681
+ };
1682
+ if (!this.socket || !this.usable) throw new TypeError("SMTP connection is closed.");
1683
+ const stream = "raw" in message ? {
1684
+ size: node_buffer.Buffer.byteLength(message.raw) + 2,
1685
+ async *read(signal$1) {
1686
+ signal$1?.throwIfAborted();
1687
+ yield node_buffer.Buffer.from(message.raw + "\r\n");
1688
+ }
1689
+ } : await prepareOnSocket(this.socket, this.config.socketTimeout, (signal$1, progress) => prepareMessageStream(message, checkSize, progress, signal$1), signal);
1690
+ if (stream.size != null) {
1691
+ checkSize(stream.size);
1692
+ if (sizeCapability != null) sizeParameter = ` SIZE=${stream.size}`;
1693
+ }
1075
1694
  const mailCommand = `MAIL FROM:<${message.envelope.from ?? ""}>${sizeParameter}${smtpUtf8Parameters}${mailDsnParameters}`;
1076
1695
  const recipientCommands = message.envelope.to.map((recipient, index) => {
1077
1696
  const parameters = dsn?.recipientParameters[index] ?? [];
@@ -1120,8 +1739,7 @@ var SmtpConnection = class {
1120
1739
  }
1121
1740
  const dataResponse = await this.sendCommand("DATA", signal);
1122
1741
  if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
1123
- const content = message.raw.replace(/\n\./g, "\n..");
1124
- const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
1742
+ const finalResponse = await writeMessageData(this.socket, (signal$1, progress) => stream.read(signal$1, progress), this.config.socketTimeout, (size) => checkSize(size, "data"), signal);
1125
1743
  if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
1126
1744
  const messageId = this.extractMessageId(finalResponse.message);
1127
1745
  return {
@@ -1271,296 +1889,71 @@ function resolveSmtpEnvelope(message, override) {
1271
1889
  }
1272
1890
 
1273
1891
  //#endregion
1274
- //#region src/dkim/canonicalize.ts
1275
- /**
1276
- * DKIM Canonicalization algorithms per RFC 6376 Section 3.4.
1277
- *
1278
- * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4
1279
- * @since 0.4.0
1280
- */
1281
- /**
1282
- * Simple header canonicalization.
1283
- *
1284
- * The "simple" header canonicalization algorithm does not change header
1285
- * fields in any way. Header fields are presented to the signing or
1286
- * verification algorithm exactly as they are in the message.
1287
- *
1288
- * @param name - The header field name
1289
- * @param value - The header field value
1290
- * @returns The canonicalized header line (name:value)
1291
- * @see RFC 6376 Section 3.4.1
1292
- * @since 0.4.0
1293
- */
1294
- function canonicalizeHeaderSimple(name, value) {
1295
- return `${name}:${value}`;
1296
- }
1297
- /**
1298
- * Relaxed header canonicalization.
1299
- *
1300
- * The "relaxed" header canonicalization algorithm:
1301
- * - Convert header field names to lowercase
1302
- * - Unfold header field continuation lines
1303
- * - Collapse whitespace sequences to a single space
1304
- * - Remove leading and trailing whitespace from header field values
1305
- *
1306
- * @param name - The header field name
1307
- * @param value - The header field value
1308
- * @returns The canonicalized header line (name:value)
1309
- * @see RFC 6376 Section 3.4.2
1310
- * @since 0.4.0
1311
- */
1312
- function canonicalizeHeaderRelaxed(name, value) {
1313
- const canonicalName = name.toLowerCase();
1314
- const canonicalValue = value.replace(/\r\n[\t ]+/g, " ").replace(/[\t ]+/g, " ").trim();
1315
- return `${canonicalName}:${canonicalValue}`;
1316
- }
1317
- /**
1318
- * Simple body canonicalization.
1319
- *
1320
- * The "simple" body canonicalization algorithm:
1321
- * - Ignores all empty lines at the end of the message body
1322
- * - If the body is empty, a single CRLF is appended
1323
- * - If there is no trailing CRLF on the body, a CRLF is added
1324
- *
1325
- * @param body - The message body
1326
- * @returns The canonicalized body
1327
- * @see RFC 6376 Section 3.4.3
1328
- * @since 0.4.0
1329
- */
1330
- function canonicalizeBodySimple(body) {
1331
- if (body === "") return "\r\n";
1332
- let result = body.replace(/(\r\n)+$/, "");
1333
- if (!result.endsWith("\r\n")) result += "\r\n";
1334
- return result;
1335
- }
1336
- /**
1337
- * Relaxed body canonicalization.
1338
- *
1339
- * The "relaxed" body canonicalization algorithm:
1340
- * - Reduce all sequences of WSP within a line to a single SP
1341
- * - Remove all trailing WSP at the end of each line (before CRLF)
1342
- * - Ignore all empty lines at the end of the message body
1343
- * - If the body is non-empty and doesn't end with CRLF, add CRLF
1344
- *
1345
- * @param body - The message body
1346
- * @returns The canonicalized body
1347
- * @see RFC 6376 Section 3.4.4
1348
- * @since 0.4.0
1349
- */
1350
- function canonicalizeBodyRelaxed(body) {
1351
- if (body === "") return "";
1352
- let processedBody = body;
1353
- if (!processedBody.endsWith("\r\n")) processedBody += "\r\n";
1354
- const lines = processedBody.split("\r\n");
1355
- const canonicalizedLines = [];
1356
- for (let i = 0; i < lines.length - 1; i++) {
1357
- let line = lines[i];
1358
- line = line.replace(/[\t ]+/g, " ");
1359
- line = line.replace(/[\t ]+$/, "");
1360
- canonicalizedLines.push(line);
1361
- }
1362
- while (canonicalizedLines.length > 0 && canonicalizedLines[canonicalizedLines.length - 1] === "") canonicalizedLines.pop();
1363
- if (canonicalizedLines.length === 0) return "";
1364
- return canonicalizedLines.join("\r\n") + "\r\n";
1365
- }
1366
-
1367
- //#endregion
1368
- //#region src/dkim/types.ts
1369
- /**
1370
- * Default header fields to sign if not specified.
1371
- *
1372
- * @since 0.4.0
1373
- */
1374
- const DEFAULT_SIGNED_HEADERS = [
1375
- "from",
1376
- "to",
1377
- "subject",
1378
- "date"
1379
- ];
1380
- /**
1381
- * Default DKIM algorithm.
1382
- *
1383
- * @since 0.4.0
1384
- */
1385
- const DEFAULT_ALGORITHM = "rsa-sha256";
1386
- /**
1387
- * Default canonicalization method.
1388
- *
1389
- * @since 0.4.0
1390
- */
1391
- const DEFAULT_CANONICALIZATION = "relaxed/relaxed";
1392
-
1393
- //#endregion
1394
- //#region src/dkim/sign.ts
1395
- /**
1396
- * Signs a raw email message with DKIM.
1397
- *
1398
- * @param rawMessage - The complete raw email message (headers + body)
1399
- * @param config - DKIM signature configuration
1400
- * @returns The DKIM-Signature header result
1401
- * @throws Error if signing fails (e.g., invalid private key)
1402
- * @since 0.4.0
1403
- */
1404
- async function signMessage(rawMessage, config) {
1405
- const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
1406
- const canonicalization = config.canonicalization ?? DEFAULT_CANONICALIZATION;
1407
- const headerFields = config.headerFields ?? DEFAULT_SIGNED_HEADERS;
1408
- const { headers, body } = parseMessage(rawMessage);
1409
- const [headerCanon, bodyCanon] = canonicalization.split("/");
1410
- const privateKey = await getPrivateKey(config.privateKey, algorithm);
1411
- const bodyHash = await computeBodyHash(body, bodyCanon);
1412
- const dkimHeaderValue = buildDkimHeaderValue({
1413
- algorithm,
1414
- canonicalization,
1415
- signingDomain: config.signingDomain,
1416
- selector: config.selector,
1417
- headerFields,
1418
- bodyHash
1419
- });
1420
- const signatureData = buildSignatureData(headers, headerFields, headerCanon, dkimHeaderValue);
1421
- const signature = await signData(signatureData, privateKey, algorithm);
1422
- return {
1423
- headerName: "DKIM-Signature",
1424
- signature: `${dkimHeaderValue} b=${signature}`
1425
- };
1426
- }
1427
- /**
1428
- * Parses a raw email message into headers and body.
1429
- */
1430
- function parseMessage(rawMessage) {
1431
- const separatorIndex = rawMessage.indexOf("\r\n\r\n");
1432
- if (separatorIndex === -1) return {
1433
- headers: parseHeaders(rawMessage),
1434
- body: ""
1435
- };
1436
- const headerSection = rawMessage.substring(0, separatorIndex);
1437
- const body = rawMessage.substring(separatorIndex + 4);
1438
- return {
1439
- headers: parseHeaders(headerSection),
1440
- body
1441
- };
1442
- }
1443
- /**
1444
- * Parses header section into a map of header name to value.
1445
- * Handles folded headers (continuation lines).
1446
- */
1447
- function parseHeaders(headerSection) {
1448
- const headers = /* @__PURE__ */ new Map();
1449
- const lines = headerSection.split("\r\n");
1450
- let currentName = "";
1451
- let currentValue = "";
1452
- for (const line of lines) if (line.startsWith(" ") || line.startsWith(" ")) currentValue += "\r\n" + line;
1453
- else {
1454
- if (currentName) headers.set(currentName.toLowerCase(), currentValue);
1455
- const colonIndex = line.indexOf(":");
1456
- if (colonIndex > 0) {
1457
- currentName = line.substring(0, colonIndex);
1458
- currentValue = line.substring(colonIndex + 1);
1892
+ //#region src/mime-stream.ts
1893
+ /** Encodes base64 without retaining producer-owned carry bytes. */
1894
+ async function* encodeAttachment(content, signal, progress) {
1895
+ const carry = new Uint8Array(3);
1896
+ let carried = 0;
1897
+ let column = 0;
1898
+ function wrap(encoded) {
1899
+ const parts = [];
1900
+ let offset = 0;
1901
+ while (offset < encoded.length) {
1902
+ if (column === 76) {
1903
+ parts.push("\r\n");
1904
+ column = 0;
1905
+ }
1906
+ const take = Math.min(76 - column, encoded.length - offset);
1907
+ parts.push(encoded.slice(offset, offset + take));
1908
+ offset += take;
1909
+ column += take;
1459
1910
  }
1911
+ return node_buffer.Buffer.from(parts.join(""));
1460
1912
  }
1461
- if (currentName) headers.set(currentName.toLowerCase(), currentValue);
1462
- return headers;
1463
- }
1464
- /**
1465
- * Gets the private key, either using a provided CryptoKey or importing from PEM.
1466
- */
1467
- function getPrivateKey(key, algorithm) {
1468
- if (typeof key !== "string") return key;
1469
- return importPrivateKey(key, algorithm);
1470
- }
1471
- /**
1472
- * Imports a PEM-encoded private key for use with Web Crypto API.
1473
- */
1474
- async function importPrivateKey(pem, algorithm) {
1475
- try {
1476
- const pemContents = pem.replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/, "").replace(/-----END (?:RSA )?PRIVATE KEY-----/, "").replace(/\s/g, "");
1477
- const binaryString = atob(pemContents);
1478
- const bytes = new Uint8Array(binaryString.length);
1479
- for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1480
- const keyAlgorithm = algorithm === "ed25519-sha256" ? { name: "Ed25519" } : {
1481
- name: "RSASSA-PKCS1-v1_5",
1482
- hash: "SHA-256"
1483
- };
1484
- return await crypto.subtle.importKey("pkcs8", bytes, keyAlgorithm, false, ["sign"]);
1485
- } catch (error) {
1486
- throw new Error(`Failed to import private key: ${error instanceof Error ? error.message : String(error)}`);
1487
- }
1488
- }
1489
- /**
1490
- * Computes the body hash (bh= tag value).
1491
- */
1492
- async function computeBodyHash(body, canonMethod) {
1493
- const canonicalBody = canonMethod === "relaxed" ? canonicalizeBodyRelaxed(body) : canonicalizeBodySimple(body);
1494
- const encoder = new TextEncoder();
1495
- const data = encoder.encode(canonicalBody);
1496
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1497
- return arrayBufferToBase64(hashBuffer);
1498
- }
1499
- /**
1500
- * Builds the DKIM-Signature header value without the b= signature.
1501
- */
1502
- function buildDkimHeaderValue(params) {
1503
- const parts = [
1504
- "v=1",
1505
- `a=${params.algorithm}`,
1506
- `c=${params.canonicalization}`,
1507
- `d=${params.signingDomain}`,
1508
- `s=${params.selector}`,
1509
- `h=${params.headerFields.join(":")}`,
1510
- `bh=${params.bodyHash};`
1511
- ];
1512
- return parts.join("; ");
1513
- }
1514
- /**
1515
- * Builds the data to be signed (canonicalized headers + DKIM-Signature header).
1516
- */
1517
- function buildSignatureData(headers, headerFields, canonMethod, dkimHeaderValue) {
1518
- const lines = [];
1519
- for (const field of headerFields) {
1520
- const value = headers.get(field.toLowerCase());
1521
- if (value !== void 0) {
1522
- const canonicalized = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed(field, value) : canonicalizeHeaderSimple(field, value);
1523
- lines.push(canonicalized);
1913
+ let processed = 0;
1914
+ for await (const chunk of (0, __upyo_core.iterateAttachmentContent)(content, signal)) {
1915
+ if (chunk.length > 0) progress?.();
1916
+ let offset = 0;
1917
+ if (carried > 0) {
1918
+ while (carried < 3 && offset < chunk.length) carry[carried++] = chunk[offset++];
1919
+ if (carried === 3) {
1920
+ yield wrap(node_buffer.Buffer.from(carry).toString("base64"));
1921
+ carried = 0;
1922
+ }
1524
1923
  }
1924
+ while (offset + 3 <= chunk.length) {
1925
+ signal?.throwIfAborted();
1926
+ const length = Math.min(45 * 1024, Math.floor((chunk.length - offset) / 3) * 3);
1927
+ yield wrap(node_buffer.Buffer.from(chunk.buffer, chunk.byteOffset + offset, length).toString("base64"));
1928
+ offset += length;
1929
+ processed += length;
1930
+ if (processed >= 1024 * 1024) {
1931
+ await new Promise((resolve) => setTimeout(resolve, 0));
1932
+ processed = 0;
1933
+ }
1934
+ }
1935
+ while (offset < chunk.length) carry[carried++] = chunk[offset++];
1525
1936
  }
1526
- const dkimHeader = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed("DKIM-Signature", " " + dkimHeaderValue + " b=") : canonicalizeHeaderSimple("DKIM-Signature", " " + dkimHeaderValue + " b=");
1527
- lines.push(dkimHeader);
1528
- return lines.join("\r\n");
1529
- }
1530
- /**
1531
- * Signs data using the appropriate algorithm.
1532
- */
1533
- async function signData(data, privateKey, algorithm) {
1534
- const encoder = new TextEncoder();
1535
- const dataBuffer = encoder.encode(data);
1536
- const signAlgorithm = algorithm === "ed25519-sha256" ? "Ed25519" : "RSASSA-PKCS1-v1_5";
1537
- const signature = await crypto.subtle.sign(signAlgorithm, privateKey, dataBuffer);
1538
- return arrayBufferToBase64(signature);
1937
+ if (carried > 0) yield wrap(node_buffer.Buffer.from(carry.subarray(0, carried)).toString("base64"));
1539
1938
  }
1540
- /**
1541
- * Converts an ArrayBuffer to a Base64 string.
1542
- */
1543
- function arrayBufferToBase64(buffer) {
1544
- const bytes = new Uint8Array(buffer);
1545
- let binary = "";
1546
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
1547
- return btoa(binary);
1939
+ /** Encoded payload length, excluding MIME framing and a trailing CRLF. */
1940
+ function attachmentEncodedSize(length) {
1941
+ const encoded = 4 * Math.ceil(length / 3);
1942
+ return encoded + (encoded === 0 ? 0 : 2 * Math.floor((encoded - 1) / 76));
1548
1943
  }
1549
1944
 
1550
1945
  //#endregion
1551
1946
  //#region src/message-converter.ts
1552
1947
  /**
1553
- * Converts a message to its SMTP envelope and wire representation.
1554
- *
1555
- * @param message The message to convert.
1556
- * @param dkimConfig Optional DKIM signing configuration.
1557
- * @param dsn Optional validated SMTP delivery status notification parameters.
1558
- * @param resolvedEnvelope The validated effective SMTP envelope.
1559
- * @returns The converted SMTP message.
1560
- * @throws {RangeError} If a header contains a token that cannot be folded
1561
- * within the RFC 5322 hard line-length limit.
1948
+ * Freezes message metadata without reading attachment content.
1949
+ * @param message Message whose metadata will be frozen.
1950
+ * @param dkimConfig Optional signing configuration.
1951
+ * @param dsn Validated delivery-status parameters.
1952
+ * @param resolvedEnvelope Validated effective SMTP envelope.
1953
+ * @returns A deterministic MIME plan for one send attempt.
1954
+ * @throws {RangeError} If a header cannot fit the RFC 5322 line limit.
1562
1955
  */
1563
- async function convertMessage(message, dkimConfig, dsn, resolvedEnvelope = resolveSmtpEnvelope(message)) {
1956
+ function prepareMessage(message, dkimConfig, dsn, resolvedEnvelope = resolveSmtpEnvelope(message)) {
1564
1957
  const envelope = {
1565
1958
  ...resolvedEnvelope,
1566
1959
  dsn
@@ -1573,23 +1966,45 @@ async function convertMessage(message, dkimConfig, dsn, resolvedEnvelope = resol
1573
1966
  ];
1574
1967
  const envelopeAddresses = [...envelope.from == null ? [] : [envelope.from], ...envelope.to];
1575
1968
  const requiresSmtpUtf8 = [...headerAddresses, ...envelopeAddresses].some((address) => Array.from(address).some((character) => (character.codePointAt(0) ?? 0) > 127));
1576
- let raw = await buildRawMessage(message);
1577
- if (dkimConfig) try {
1578
- for (const sig of dkimConfig.signatures) {
1579
- const result = await signMessage(raw, sig);
1580
- raw = `${result.headerName}: ${result.signature}\r\n${raw}`;
1581
- }
1582
- } catch (error) {
1583
- if (dkimConfig.onSigningFailure === "send-unsigned") console.warn("DKIM signing failed, sending unsigned:", error);
1584
- else throw error;
1585
- }
1969
+ const parts = buildMimeParts(message);
1970
+ const first = parts[0];
1971
+ if (typeof first !== "string") throw new TypeError("Missing MIME headers.");
1972
+ const separator = first.indexOf("\r\n\r\n") + 4;
1973
+ const headers = first.slice(0, separator);
1974
+ parts[0] = first.slice(separator);
1586
1975
  return {
1587
1976
  envelope,
1588
- raw,
1589
- requiresSmtpUtf8
1977
+ requiresSmtpUtf8,
1978
+ dkim: dkimConfig,
1979
+ headers,
1980
+ async *body(signal, progress) {
1981
+ for (const part of parts) {
1982
+ signal?.throwIfAborted();
1983
+ if (typeof part === "string") {
1984
+ const bytes = node_buffer.Buffer.from(part);
1985
+ for (let offset = 0; offset < bytes.length; offset += 65536) yield bytes.subarray(offset, offset + 65536);
1986
+ } else yield* encodeAttachment(part.content, signal, progress);
1987
+ }
1988
+ },
1989
+ async size(signal, checkSize) {
1990
+ let size = node_buffer.Buffer.byteLength(headers);
1991
+ let unknown = false;
1992
+ for (const part of parts) {
1993
+ signal?.throwIfAborted();
1994
+ if (typeof part === "string") size += node_buffer.Buffer.byteLength(part);
1995
+ else {
1996
+ if (part.content instanceof Promise) part.content = await (0, __upyo_core.readAttachmentContent)(part.content, signal);
1997
+ if (typeof part.content === "function") unknown = true;
1998
+ else size += attachmentEncodedSize(part.content instanceof Uint8Array ? part.content.byteLength : part.content.size);
1999
+ }
2000
+ if (!Number.isSafeInteger(size)) throw new RangeError("Message size exceeds the safe integer range.");
2001
+ }
2002
+ checkSize?.(size);
2003
+ return unknown ? void 0 : size;
2004
+ }
1590
2005
  };
1591
2006
  }
1592
- async function buildRawMessage(message) {
2007
+ function buildMimeParts(message) {
1593
2008
  const lines = [];
1594
2009
  const boundary = generateBoundary();
1595
2010
  const hasAttachments = message.attachments.length > 0;
@@ -1654,7 +2069,7 @@ async function buildRawMessage(message) {
1654
2069
  lines.push(`Content-ID: <${attachment.contentId}>`);
1655
2070
  } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1656
2071
  lines.push("");
1657
- lines.push(encodeBase64(await attachment.content));
2072
+ lines.push({ content: attachment.content });
1658
2073
  }
1659
2074
  lines.push("");
1660
2075
  lines.push(`--${boundary}--`);
@@ -1669,7 +2084,18 @@ async function buildRawMessage(message) {
1669
2084
  lines.push("");
1670
2085
  lines.push(encodeQuotedPrintable(message.content.text));
1671
2086
  }
1672
- return lines.join("\r\n");
2087
+ const parts = [];
2088
+ let text = "";
2089
+ for (const line of lines) {
2090
+ if (typeof line === "string") text += line;
2091
+ else {
2092
+ parts.push(text, line);
2093
+ text = "";
2094
+ }
2095
+ text += "\r\n";
2096
+ }
2097
+ parts.push(text);
2098
+ return parts;
1673
2099
  }
1674
2100
  function generateBoundary() {
1675
2101
  return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -1784,10 +2210,6 @@ function encodeQuotedPrintable(text) {
1784
2210
  }
1785
2211
  return result;
1786
2212
  }
1787
- function encodeBase64(data) {
1788
- const base64 = node_buffer.Buffer.from(data).toString("base64");
1789
- return base64.replace(/(.{76})/g, "$1\r\n").trim();
1790
- }
1791
2213
 
1792
2214
  //#endregion
1793
2215
  //#region src/smtp-transport.ts
@@ -1849,6 +2271,7 @@ var SmtpTransport = class {
1849
2271
  * and options.
1850
2272
  */
1851
2273
  constructor(config) {
2274
+ validateDkimBodyMode(config.dkim);
1852
2275
  this.config = config;
1853
2276
  this.poolSize = config.poolSize ?? 5;
1854
2277
  const auth = config.auth;
@@ -1890,7 +2313,7 @@ var SmtpTransport = class {
1890
2313
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1891
2314
  connection = await this.getConnection(options?.signal);
1892
2315
  options?.signal?.throwIfAborted();
1893
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2316
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1894
2317
  options?.signal?.throwIfAborted();
1895
2318
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1896
2319
  await this.returnConnection(connection);
@@ -1901,7 +2324,7 @@ var SmtpTransport = class {
1901
2324
  rejectedRecipients: result.rejectedRecipients
1902
2325
  };
1903
2326
  } catch (error) {
1904
- if (connection != null) if (isReusableLocalFailure(error)) await this.returnConnection(connection);
2327
+ if (connection != null) if (connection.usable && isReusableLocalFailure(error)) await this.returnConnection(connection);
1905
2328
  else await this.discardConnection(connection);
1906
2329
  options?.signal?.throwIfAborted();
1907
2330
  return createSmtpFailure(error instanceof Error ? error.message : String(error), error);
@@ -1965,7 +2388,7 @@ var SmtpTransport = class {
1965
2388
  try {
1966
2389
  const envelope = resolveEnvelopeOption(message, options?.envelope, index++);
1967
2390
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1968
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2391
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1969
2392
  options?.signal?.throwIfAborted();
1970
2393
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1971
2394
  yield {
@@ -1976,7 +2399,7 @@ var SmtpTransport = class {
1976
2399
  };
1977
2400
  } catch (error) {
1978
2401
  options?.signal?.throwIfAborted();
1979
- if (!isReusableLocalFailure(error)) connectionValid = false;
2402
+ if (!connection.usable || !isReusableLocalFailure(error)) connectionValid = false;
1980
2403
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1981
2404
  }
1982
2405
  }
@@ -1991,7 +2414,7 @@ var SmtpTransport = class {
1991
2414
  try {
1992
2415
  const envelope = resolveEnvelopeOption(message, options?.envelope, index++);
1993
2416
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1994
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2417
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1995
2418
  options?.signal?.throwIfAborted();
1996
2419
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1997
2420
  yield {
@@ -2002,7 +2425,7 @@ var SmtpTransport = class {
2002
2425
  };
2003
2426
  } catch (error) {
2004
2427
  options?.signal?.throwIfAborted();
2005
- if (!isReusableLocalFailure(error)) connectionValid = false;
2428
+ if (!connection.usable || !isReusableLocalFailure(error)) connectionValid = false;
2006
2429
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
2007
2430
  }
2008
2431
  }
@@ -2016,7 +2439,11 @@ var SmtpTransport = class {
2016
2439
  }
2017
2440
  async getConnection(signal) {
2018
2441
  signal?.throwIfAborted();
2019
- if (this.connectionPool.length > 0) return this.connectionPool.pop();
2442
+ while (this.connectionPool.length > 0) {
2443
+ const connection$1 = this.connectionPool.pop();
2444
+ if (connection$1.usable) return connection$1;
2445
+ await this.discardConnection(connection$1);
2446
+ }
2020
2447
  const connection = new SmtpConnection(this.config, this.tokenManager);
2021
2448
  try {
2022
2449
  await this.connectAndSetup(connection, signal);
@@ -2044,7 +2471,7 @@ var SmtpTransport = class {
2044
2471
  await connection.authenticate(signal);
2045
2472
  }
2046
2473
  async returnConnection(connection) {
2047
- if (!connection.config.pool) {
2474
+ if (!connection.usable || !connection.config.pool) {
2048
2475
  await connection.quit();
2049
2476
  return;
2050
2477
  }
@@ -2119,6 +2546,13 @@ function createSmtpFailure(message, error) {
2119
2546
  retryable: false,
2120
2547
  attempts: 1
2121
2548
  });
2549
+ if (error instanceof SmtpAttachmentReplayError) return (0, __upyo_core.createFailedReceipt)(message, {
2550
+ provider: "smtp",
2551
+ code: "smtp.attachment-replay-mismatch",
2552
+ category: "validation",
2553
+ retryable: false,
2554
+ attempts: 1
2555
+ });
2122
2556
  if (error instanceof SmtpMessageSizeError) return (0, __upyo_core.createFailedReceipt)(message, {
2123
2557
  provider: "smtp",
2124
2558
  code: "smtp.message-size-exceeded",
@@ -2161,7 +2595,7 @@ function createSmtpFailure(message, error) {
2161
2595
  });
2162
2596
  }
2163
2597
  function isReusableLocalFailure(error) {
2164
- return error instanceof SmtpMessageSizeError || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpEnvelopeValidationError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2598
+ return error instanceof SmtpMessageSizeError && error.phase === "preflight" || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpEnvelopeValidationError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2165
2599
  }
2166
2600
  function resolveEnvelopeOption(message, option, index) {
2167
2601
  let override;
@@ -2213,6 +2647,7 @@ function isSmtpResponseProviderDetails(value) {
2213
2647
  }
2214
2648
 
2215
2649
  //#endregion
2650
+ exports.SmtpAttachmentReplayError = SmtpAttachmentReplayError;
2216
2651
  exports.SmtpAuthError = SmtpAuthError;
2217
2652
  exports.SmtpDsnUnsupportedError = SmtpDsnUnsupportedError;
2218
2653
  exports.SmtpDsnValidationError = SmtpDsnValidationError;