@upyo/smtp 0.6.0-dev.311 → 0.6.0-dev.314

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,561 @@ 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
+ const relaxed = this.mode === "relaxed";
524
+ for (let offset = 0; offset < bytes.length;) {
525
+ const byte = bytes[offset++];
526
+ if (this.cr) {
527
+ this.cr = false;
528
+ if (byte === 10) {
529
+ this.whitespace = false;
530
+ this.newlines++;
531
+ continue;
532
+ }
533
+ this.content(13);
534
+ }
535
+ if (byte === 13) this.cr = true;
536
+ else if (relaxed && (byte === 32 || byte === 9)) this.whitespace = true;
537
+ else {
538
+ this.content(byte);
539
+ const start = offset;
540
+ while (offset < bytes.length && bytes[offset] !== 13 && (!relaxed || bytes[offset] !== 32 && bytes[offset] !== 9)) offset++;
541
+ this.emitSpan(bytes, start, offset);
542
+ }
543
+ }
544
+ }
545
+ emitSpan(bytes, start, end) {
546
+ while (start < end) {
547
+ const length = Math.min(end - start, this.buffer.length - this.used);
548
+ this.buffer.set(bytes.subarray(start, start + length), this.used);
549
+ this.used += length;
550
+ start += length;
551
+ if (this.used === this.buffer.length) {
552
+ this.hash.update(this.buffer);
553
+ this.used = 0;
554
+ }
555
+ }
556
+ }
557
+ content(byte) {
558
+ if (this.mode === "relaxed" && (byte === 32 || byte === 9)) {
559
+ this.whitespace = true;
560
+ return;
561
+ }
562
+ while (this.newlines > 0) {
563
+ this.emit(13);
564
+ this.emit(10);
565
+ this.newlines--;
566
+ }
567
+ if (this.whitespace) {
568
+ this.emit(32);
569
+ this.whitespace = false;
570
+ }
571
+ this.emit(byte);
572
+ this.nonempty = true;
573
+ }
574
+ emit(byte) {
575
+ this.buffer[this.used++] = byte;
576
+ if (this.used === this.buffer.length) {
577
+ this.hash.update(this.buffer);
578
+ this.used = 0;
579
+ }
580
+ }
581
+ digest() {
582
+ if (this.cr) this.content(13);
583
+ if (this.nonempty || this.mode === "simple") {
584
+ this.emit(13);
585
+ this.emit(10);
586
+ }
587
+ this.hash.update(this.buffer.subarray(0, this.used));
588
+ return this.hash.digest("base64");
589
+ }
590
+ };
591
+
592
+ //#endregion
593
+ //#region src/dkim/canonicalize.ts
594
+ /**
595
+ * DKIM Canonicalization algorithms per RFC 6376 Section 3.4.
596
+ *
597
+ * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4
598
+ * @since 0.4.0
599
+ */
600
+ /**
601
+ * Simple header canonicalization.
602
+ *
603
+ * The "simple" header canonicalization algorithm does not change header
604
+ * fields in any way. Header fields are presented to the signing or
605
+ * verification algorithm exactly as they are in the message.
606
+ *
607
+ * @param name - The header field name
608
+ * @param value - The header field value
609
+ * @returns The canonicalized header line (name:value)
610
+ * @see RFC 6376 Section 3.4.1
611
+ * @since 0.4.0
612
+ */
613
+ function canonicalizeHeaderSimple(name, value) {
614
+ return `${name}:${value}`;
615
+ }
616
+ /**
617
+ * Relaxed header canonicalization.
618
+ *
619
+ * The "relaxed" header canonicalization algorithm:
620
+ * - Convert header field names to lowercase
621
+ * - Unfold header field continuation lines
622
+ * - Collapse whitespace sequences to a single space
623
+ * - Remove leading and trailing whitespace from header field values
624
+ *
625
+ * @param name - The header field name
626
+ * @param value - The header field value
627
+ * @returns The canonicalized header line (name:value)
628
+ * @see RFC 6376 Section 3.4.2
629
+ * @since 0.4.0
630
+ */
631
+ function canonicalizeHeaderRelaxed(name, value) {
632
+ const canonicalName = name.toLowerCase();
633
+ const canonicalValue = value.replace(/\r\n[\t ]+/g, " ").replace(/[\t ]+/g, " ").trim();
634
+ return `${canonicalName}:${canonicalValue}`;
635
+ }
636
+
637
+ //#endregion
638
+ //#region src/dkim/sign.ts
639
+ /**
640
+ * Signs frozen headers using a body hash computed by the MIME reader.
641
+ * @param rawHeaders Frozen wire headers, including earlier signatures.
642
+ * @param config Signature configuration.
643
+ * @param bodyHash Canonical body SHA-256 digest, encoded as base64.
644
+ * @param signal Optional cancellation signal.
645
+ * @returns A DKIM-Signature header value.
646
+ * @throws {Error} If key import, signing, or cancellation fails.
647
+ */
648
+ async function signWithBodyHash(rawHeaders, config, bodyHash, signal) {
649
+ signal?.throwIfAborted();
650
+ const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
651
+ const canonicalization = config.canonicalization ?? DEFAULT_CANONICALIZATION;
652
+ const headerFields = config.headerFields ?? DEFAULT_SIGNED_HEADERS;
653
+ const { headers } = parseMessage(rawHeaders);
654
+ const [headerCanon] = canonicalization.split("/");
655
+ const privateKey = await getPrivateKey(config.privateKey, algorithm);
656
+ signal?.throwIfAborted();
657
+ const dkimHeaderValue = buildDkimHeaderValue({
658
+ algorithm,
659
+ canonicalization,
660
+ signingDomain: config.signingDomain,
661
+ selector: config.selector,
662
+ headerFields,
663
+ bodyHash
664
+ });
665
+ const signatureData = buildSignatureData(headers, headerFields, headerCanon, dkimHeaderValue);
666
+ const signature = await signData(signatureData, privateKey, algorithm);
667
+ signal?.throwIfAborted();
668
+ return {
669
+ headerName: "DKIM-Signature",
670
+ signature: `${dkimHeaderValue} b=${signature}`
671
+ };
672
+ }
673
+ /**
674
+ * Parses a raw email message into headers and body.
675
+ */
676
+ function parseMessage(rawMessage) {
677
+ const separatorIndex = rawMessage.indexOf("\r\n\r\n");
678
+ if (separatorIndex === -1) return {
679
+ headers: parseHeaders(rawMessage),
680
+ body: ""
681
+ };
682
+ const headerSection = rawMessage.substring(0, separatorIndex);
683
+ const body = rawMessage.substring(separatorIndex + 4);
684
+ return {
685
+ headers: parseHeaders(headerSection),
686
+ body
687
+ };
688
+ }
689
+ /**
690
+ * Parses header section into a map of header name to value.
691
+ * Handles folded headers (continuation lines).
692
+ */
693
+ function parseHeaders(headerSection) {
694
+ const headers = /* @__PURE__ */ new Map();
695
+ const lines = headerSection.split("\r\n");
696
+ let currentName = "";
697
+ let currentValue = "";
698
+ for (const line of lines) if (line.startsWith(" ") || line.startsWith(" ")) currentValue += "\r\n" + line;
699
+ else {
700
+ if (currentName) headers.set(currentName.toLowerCase(), {
701
+ name: currentName,
702
+ value: currentValue
703
+ });
704
+ const colonIndex = line.indexOf(":");
705
+ if (colonIndex > 0) {
706
+ currentName = line.substring(0, colonIndex);
707
+ currentValue = line.substring(colonIndex + 1);
708
+ }
709
+ }
710
+ if (currentName) headers.set(currentName.toLowerCase(), {
711
+ name: currentName,
712
+ value: currentValue
713
+ });
714
+ return headers;
715
+ }
716
+ /**
717
+ * Gets the private key, either using a provided CryptoKey or importing from PEM.
718
+ */
719
+ function getPrivateKey(key, algorithm) {
720
+ if (typeof key !== "string") return key;
721
+ return importPrivateKey(key, algorithm);
722
+ }
723
+ /**
724
+ * Imports a PEM-encoded private key for use with Web Crypto API.
725
+ */
726
+ async function importPrivateKey(pem, algorithm) {
727
+ try {
728
+ const pemContents = pem.replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/, "").replace(/-----END (?:RSA )?PRIVATE KEY-----/, "").replace(/\s/g, "");
729
+ const binaryString = atob(pemContents);
730
+ const bytes = new Uint8Array(binaryString.length);
731
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
732
+ const keyAlgorithm = algorithm === "ed25519-sha256" ? { name: "Ed25519" } : {
733
+ name: "RSASSA-PKCS1-v1_5",
734
+ hash: "SHA-256"
735
+ };
736
+ return await crypto.subtle.importKey("pkcs8", bytes, keyAlgorithm, false, ["sign"]);
737
+ } catch (error) {
738
+ throw new Error(`Failed to import private key: ${error instanceof Error ? error.message : String(error)}`);
739
+ }
740
+ }
741
+ /**
742
+ * Builds the DKIM-Signature header value without the b= signature.
743
+ */
744
+ function buildDkimHeaderValue(params) {
745
+ const parts = [
746
+ "v=1",
747
+ `a=${params.algorithm}`,
748
+ `c=${params.canonicalization}`,
749
+ `d=${params.signingDomain}`,
750
+ `s=${params.selector}`,
751
+ `h=${params.headerFields.join(":")}`,
752
+ `bh=${params.bodyHash};`
753
+ ];
754
+ return parts.join("; ");
755
+ }
756
+ /**
757
+ * Builds the data to be signed (canonicalized headers + DKIM-Signature header).
758
+ */
759
+ function buildSignatureData(headers, headerFields, canonMethod, dkimHeaderValue) {
760
+ const lines = [];
761
+ for (const field of headerFields) {
762
+ const header = headers.get(field.toLowerCase());
763
+ if (header !== void 0) {
764
+ const canonicalized = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed(header.name, header.value) : canonicalizeHeaderSimple(header.name, header.value);
765
+ lines.push(canonicalized);
766
+ }
767
+ }
768
+ const dkimHeader = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed("DKIM-Signature", " " + dkimHeaderValue + " b=") : canonicalizeHeaderSimple("DKIM-Signature", " " + dkimHeaderValue + " b=");
769
+ lines.push(dkimHeader);
770
+ return lines.join("\r\n");
771
+ }
772
+ /**
773
+ * Signs data using the appropriate algorithm.
774
+ */
775
+ async function signData(data, privateKey, algorithm) {
776
+ const encoder = new TextEncoder();
777
+ const dataBuffer = encoder.encode(data);
778
+ const signAlgorithm = algorithm === "ed25519-sha256" ? "Ed25519" : "RSASSA-PKCS1-v1_5";
779
+ const signingInput = algorithm === "ed25519-sha256" ? await crypto.subtle.digest("SHA-256", dataBuffer) : dataBuffer;
780
+ const signature = await crypto.subtle.sign(signAlgorithm, privateKey, signingInput);
781
+ return arrayBufferToBase64(signature);
782
+ }
783
+ /**
784
+ * Converts an ArrayBuffer to a Base64 string.
785
+ */
786
+ function arrayBufferToBase64(buffer) {
787
+ const bytes = new Uint8Array(buffer);
788
+ let binary = "";
789
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
790
+ return btoa(binary);
791
+ }
792
+
793
+ //#endregion
794
+ //#region src/message-stream.ts
795
+ /**
796
+ * A replayable attachment changed between DKIM body reads.
797
+ * @since 0.6.0
798
+ */
799
+ var SmtpAttachmentReplayError = class extends TypeError {
800
+ /** Creates a replay validation failure. */
801
+ constructor() {
802
+ super("Attachment content changed between DKIM body reads.");
803
+ this.name = "SmtpAttachmentReplayError";
804
+ }
805
+ };
806
+ /** Prepares signatures and sizes without consuming unsigned factory sources. */
807
+ async function prepareMessageStream(plan, checkSize, progress, signal) {
808
+ const knownSize = await plan.size(signal, checkSize);
809
+ if (knownSize != null) checkSize(knownSize);
810
+ const signatures = plan.dkim?.signatures ?? [];
811
+ if (signatures.length === 0) return {
812
+ size: knownSize,
813
+ async *read(signal$1, progress$1) {
814
+ yield node_buffer.Buffer.from(plan.headers);
815
+ yield* plan.body(signal$1, progress$1);
816
+ }
817
+ };
818
+ const buffered = plan.dkim?.bodyMode !== "streaming";
819
+ const chunks = [];
820
+ const hashes = /* @__PURE__ */ new Map();
821
+ for (const sig of signatures) {
822
+ const mode = sig.canonicalization?.endsWith("/simple") ? "simple" : "relaxed";
823
+ if (!hashes.has(mode)) hashes.set(mode, new BodyHasher(mode));
824
+ }
825
+ const rawHash = (0, node_crypto.createHash)("sha256");
826
+ let length = 0;
827
+ const headerLength = node_buffer.Buffer.byteLength(plan.headers);
828
+ for await (const chunk of plan.body(signal, progress)) {
829
+ length += chunk.length;
830
+ checkSize(headerLength + length);
831
+ if (chunk.length > 0) progress();
832
+ if (buffered) chunks.push(chunk.slice());
833
+ rawHash.update(chunk);
834
+ for (const hash of hashes.values()) hash.update(chunk);
835
+ }
836
+ const expectedDigest = rawHash.digest("hex");
837
+ const bodyHashes = new Map(Array.from(hashes, ([mode, hash]) => [mode, hash.digest()]));
838
+ let headers = plan.headers;
839
+ try {
840
+ for (const sig of signatures) {
841
+ const mode = sig.canonicalization?.endsWith("/simple") ? "simple" : "relaxed";
842
+ const result = await signWithBodyHash(headers, sig, bodyHashes.get(mode), signal);
843
+ headers = `${result.headerName}: ${result.signature}\r\n${headers}`;
844
+ }
845
+ } catch (error) {
846
+ signal?.throwIfAborted();
847
+ if (plan.dkim?.onSigningFailure !== "send-unsigned") throw error;
848
+ console.warn("DKIM signing failed, sending unsigned:", error);
849
+ }
850
+ const size = node_buffer.Buffer.byteLength(headers) + length;
851
+ checkSize(size);
852
+ return {
853
+ size,
854
+ async *read(signal$1, progress$1) {
855
+ yield node_buffer.Buffer.from(headers);
856
+ if (buffered) {
857
+ for (const chunk of chunks) {
858
+ signal$1?.throwIfAborted();
859
+ yield chunk;
860
+ }
861
+ return;
862
+ }
863
+ const replayHash = (0, node_crypto.createHash)("sha256");
864
+ let replayLength = 0;
865
+ for await (const chunk of plan.body(signal$1, progress$1)) {
866
+ replayLength += chunk.length;
867
+ if (replayLength > length) throw new SmtpAttachmentReplayError();
868
+ replayHash.update(chunk);
869
+ yield chunk;
870
+ }
871
+ if (replayLength !== length || replayHash.digest("hex") !== expectedDigest) throw new SmtpAttachmentReplayError();
872
+ }
873
+ };
874
+ }
875
+
876
+ //#endregion
877
+ //#region src/data-stream.ts
878
+ /** Races preparation against source inactivity and remote socket termination. */
879
+ async function prepareOnSocket(socket, timeoutMs, prepare, signal) {
880
+ const controller = new AbortController();
881
+ const combined = (0, __upyo_core.combineSignals)(controller.signal, signal);
882
+ const fail = (error) => controller.abort(error);
883
+ const closed = () => fail(/* @__PURE__ */ new TypeError("SMTP connection closed during message preparation."));
884
+ const data = () => fail(/* @__PURE__ */ new TypeError("Unexpected SMTP reply during message preparation."));
885
+ let timer;
886
+ const progress = () => {
887
+ clearTimeout(timer);
888
+ timer = setTimeout(() => fail(/* @__PURE__ */ new TypeError("SMTP message preparation timeout.")), timeoutMs);
889
+ };
890
+ socket.on("error", fail);
891
+ socket.on("close", closed);
892
+ socket.on("data", data);
893
+ progress();
894
+ try {
895
+ if (socket.destroyed || !socket.writable) closed();
896
+ combined.signal.throwIfAborted();
897
+ return await abortable(prepare(combined.signal, progress), combined.signal);
898
+ } catch (error) {
899
+ const interrupted = combined.signal.aborted;
900
+ controller.abort(error);
901
+ if (interrupted) socket.destroy();
902
+ signal?.throwIfAborted();
903
+ throw error;
904
+ } finally {
905
+ clearTimeout(timer);
906
+ socket.off("error", fail);
907
+ socket.off("close", closed);
908
+ socket.off("data", data);
909
+ combined.cleanup();
910
+ }
911
+ }
912
+ function abortable(promise, signal) {
913
+ return new Promise((resolve, reject) => {
914
+ const abort = () => reject(signal.reason);
915
+ signal.addEventListener("abort", abort, { once: true });
916
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
917
+ if (signal.aborted) abort();
918
+ });
919
+ }
920
+ /** Writes one DATA body with bounded transparency buffers and backpressure. */
921
+ async function writeMessageData(socket, source, timeoutMs, checkSize, signal) {
922
+ const controller = new AbortController();
923
+ const combined = (0, __upyo_core.combineSignals)(controller.signal, signal);
924
+ const owned = combined.signal;
925
+ let terminated = false;
926
+ let complete = false;
927
+ let replyBytes = 0;
928
+ let buffer = "";
929
+ const lines = [];
930
+ let resolveReply;
931
+ const reply = new Promise((resolve) => resolveReply = resolve);
932
+ const fail = (error) => {
933
+ if (!owned.aborted) controller.abort(error);
934
+ socket.destroy();
935
+ };
936
+ const close = () => fail(/* @__PURE__ */ new TypeError("SMTP connection closed during DATA."));
937
+ const onAbort = () => socket.destroy();
938
+ let timer;
939
+ const progress = () => {
940
+ clearTimeout(timer);
941
+ timer = setTimeout(() => fail(/* @__PURE__ */ new TypeError("SMTP DATA timeout.")), timeoutMs);
942
+ };
943
+ const data = (chunk) => {
944
+ replyBytes += chunk.length;
945
+ if (replyBytes > 65536) {
946
+ fail(/* @__PURE__ */ new RangeError("SMTP DATA reply exceeds 64 KiB."));
947
+ return;
948
+ }
949
+ buffer += node_buffer.Buffer.from(chunk).toString("utf8");
950
+ let end;
951
+ while ((end = buffer.indexOf("\r\n")) >= 0) {
952
+ const line = buffer.slice(0, end);
953
+ buffer = buffer.slice(end + 2);
954
+ lines.push(line);
955
+ if (/^\d{3} /.test(line)) {
956
+ if (!terminated) fail(/* @__PURE__ */ new TypeError(`Premature SMTP DATA reply: ${line}`));
957
+ else resolveReply({
958
+ code: Number(line.slice(0, 3)),
959
+ message: line.slice(4),
960
+ raw: lines.join("\r\n")
961
+ });
962
+ return;
963
+ }
964
+ }
965
+ };
966
+ socket.on("error", fail);
967
+ socket.on("close", close);
968
+ socket.on("data", data);
969
+ owned.addEventListener("abort", onAbort, { once: true });
970
+ progress();
971
+ let iterator;
972
+ async function write(bytes) {
973
+ owned.throwIfAborted();
974
+ await abortable(new Promise((resolve, reject) => {
975
+ let written = false;
976
+ let drained = false;
977
+ let returned = false;
978
+ const cleanup = () => {
979
+ socket.off("drain", drain);
980
+ owned.removeEventListener("abort", cleanup);
981
+ };
982
+ const finish = () => {
983
+ if (returned && written && drained) {
984
+ cleanup();
985
+ progress();
986
+ resolve();
987
+ }
988
+ };
989
+ const drain = () => {
990
+ drained = true;
991
+ finish();
992
+ };
993
+ socket.once("drain", drain);
994
+ owned.addEventListener("abort", cleanup, { once: true });
995
+ try {
996
+ const accepted = socket.write(bytes, (error) => {
997
+ if (error != null) {
998
+ cleanup();
999
+ reject(error);
1000
+ return;
1001
+ }
1002
+ written = true;
1003
+ finish();
1004
+ });
1005
+ drained = accepted || drained;
1006
+ returned = true;
1007
+ finish();
1008
+ } catch (error) {
1009
+ cleanup();
1010
+ reject(error);
1011
+ }
1012
+ }), owned);
1013
+ }
1014
+ try {
1015
+ iterator = source(owned, progress)[Symbol.asyncIterator]();
1016
+ if (socket.destroyed || !socket.writable) close();
1017
+ let lineStart = true;
1018
+ let size = 0;
1019
+ while (true) {
1020
+ owned.throwIfAborted();
1021
+ const item = await abortable(Promise.resolve(iterator.next()), owned);
1022
+ if (item.done) break;
1023
+ const chunk = item.value;
1024
+ for (let offset = 0; offset < chunk.length; offset += 32768) {
1025
+ const window = chunk.subarray(offset, offset + 32768);
1026
+ size += window.length;
1027
+ checkSize(size);
1028
+ progress();
1029
+ const output = node_buffer.Buffer.allocUnsafe(window.length * 2);
1030
+ let length = 0;
1031
+ for (const byte of window) {
1032
+ if (lineStart && byte === 46) output[length++] = 46;
1033
+ output[length++] = byte;
1034
+ lineStart = byte === 10;
1035
+ }
1036
+ await write(output.subarray(0, length));
1037
+ }
1038
+ }
1039
+ owned.throwIfAborted();
1040
+ terminated = true;
1041
+ await write(node_buffer.Buffer.from(".\r\n"));
1042
+ const result = await abortable(reply, owned);
1043
+ owned.throwIfAborted();
1044
+ complete = true;
1045
+ return result;
1046
+ } catch (error) {
1047
+ fail(error);
1048
+ signal?.throwIfAborted();
1049
+ throw owned.reason;
1050
+ } finally {
1051
+ if (!complete) try {
1052
+ Promise.resolve(iterator?.return?.()).catch(() => {});
1053
+ } catch {}
1054
+ clearTimeout(timer);
1055
+ socket.off("error", fail);
1056
+ socket.off("close", close);
1057
+ socket.off("data", data);
1058
+ owned.removeEventListener("abort", onAbort);
1059
+ combined.cleanup();
1060
+ }
1061
+ }
1062
+
472
1063
  //#endregion
473
1064
  //#region src/smtp-status-code.ts
474
1065
  /**
@@ -503,13 +1094,13 @@ function parseEnhancedSmtpStatusCode(replyCode, response) {
503
1094
  * Error thrown when a message exceeds the fixed limit advertised through the
504
1095
  * SMTP SIZE extension.
505
1096
  *
506
- * The check happens before `MAIL FROM`, so the SMTP connection remains usable
507
- * for another message.
1097
+ * Known sizes are checked before `MAIL FROM`. Unknown source sizes are also
1098
+ * checked while writing DATA; those failures make the connection unusable.
508
1099
  *
509
1100
  * @since 0.6.0
510
1101
  */
511
1102
  var SmtpMessageSizeError = class extends RangeError {
512
- /** The encoded message size in octets. */
1103
+ /** Exact known size, or the octets counted when an unknown source is stopped. */
513
1104
  actualSize;
514
1105
  /** The fixed maximum advertised by the SMTP server. */
515
1106
  maximumSize;
@@ -518,9 +1109,11 @@ var SmtpMessageSizeError = class extends RangeError {
518
1109
  *
519
1110
  * @param actualSize The encoded message size in octets.
520
1111
  * @param maximumSize The fixed maximum advertised by the SMTP server.
1112
+ * @param phase Whether the failure occurred before or during DATA.
521
1113
  */
522
- constructor(actualSize, maximumSize) {
1114
+ constructor(actualSize, maximumSize, phase = "preflight") {
523
1115
  super(`Message size ${actualSize} octets exceeds the server's maximum of ${maximumSize} octets.`);
1116
+ this.phase = phase;
524
1117
  this.name = "SmtpMessageSizeError";
525
1118
  this.actualSize = actualSize;
526
1119
  this.maximumSize = maximumSize;
@@ -715,6 +1308,16 @@ var SmtpConnection = class {
715
1308
  authenticated = false;
716
1309
  capabilities = [];
717
1310
  tokenManager;
1311
+ active = false;
1312
+ get usable() {
1313
+ return this.socket != null && !this.socket.destroyed && this.socket.writable;
1314
+ }
1315
+ observeSocket(socket) {
1316
+ socket.on("error", () => socket.destroy());
1317
+ socket.on("timeout", () => {
1318
+ if (!this.active) socket.destroy();
1319
+ });
1320
+ }
718
1321
  constructor(config, tokenManager) {
719
1322
  this.config = createSmtpConfig(config);
720
1323
  this.tokenManager = tokenManager ?? null;
@@ -724,17 +1327,31 @@ var SmtpConnection = class {
724
1327
  signal?.throwIfAborted();
725
1328
  return new Promise((resolve, reject) => {
726
1329
  const timeout = setTimeout(() => {
1330
+ onError(/* @__PURE__ */ new TypeError("Connection timeout."));
727
1331
  this.socket?.destroy();
728
- reject(/* @__PURE__ */ new Error("Connection timeout"));
729
1332
  }, this.config.connectionTimeout);
730
- const onConnect = () => {
1333
+ const cleanup = () => {
731
1334
  clearTimeout(timeout);
1335
+ this.socket?.off("connect", onConnect);
1336
+ this.socket?.off("error", onError);
1337
+ this.socket?.off("close", onClose);
1338
+ this.socket?.off("timeout", onTimeout);
1339
+ };
1340
+ const onConnect = () => {
1341
+ cleanup();
732
1342
  resolve();
733
1343
  };
734
1344
  const onError = (error) => {
735
- clearTimeout(timeout);
1345
+ cleanup();
736
1346
  reject(error);
737
1347
  };
1348
+ const onClose = () => {
1349
+ onError(/* @__PURE__ */ new TypeError("SMTP connection closed before establishment."));
1350
+ };
1351
+ const onTimeout = () => {
1352
+ onError(/* @__PURE__ */ new TypeError("Socket timeout."));
1353
+ this.socket?.destroy();
1354
+ };
738
1355
  if (this.config.secure) this.socket = (0, node_tls.connect)({
739
1356
  host: this.config.host,
740
1357
  port: this.config.port,
@@ -750,13 +1367,11 @@ var SmtpConnection = class {
750
1367
  this.socket.connect(this.config.port, this.config.host);
751
1368
  }
752
1369
  this.socket.setTimeout(this.config.socketTimeout);
1370
+ this.observeSocket(this.socket);
753
1371
  this.socket.once("connect", onConnect);
754
1372
  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
- });
1373
+ this.socket.once("close", onClose);
1374
+ this.socket.once("timeout", onTimeout);
760
1375
  });
761
1376
  }
762
1377
  sendCommand(command, signal) {
@@ -789,7 +1404,7 @@ var SmtpConnection = class {
789
1404
  buffer += data.toString();
790
1405
  const lines = buffer.split("\r\n");
791
1406
  const incompleteLine = lines.pop() || "";
792
- for (const line of lines) {
1407
+ for (const [lineIndex, line] of lines.entries()) {
793
1408
  responseLines.push(line);
794
1409
  if (line.length >= 4 && line[3] === " ") {
795
1410
  const code = parseInt(line.substring(0, 3), 10);
@@ -804,6 +1419,11 @@ var SmtpConnection = class {
804
1419
  responseLines = [];
805
1420
  if (responses.length === commands.length) {
806
1421
  cleanup();
1422
+ if (commands[0] === "DATA" && (lineIndex + 1 < lines.length || incompleteLine.length > 0)) {
1423
+ this.socket?.destroy();
1424
+ reject(/* @__PURE__ */ new TypeError("Premature SMTP reply after DATA readiness."));
1425
+ return;
1426
+ }
807
1427
  resolve(responses);
808
1428
  return;
809
1429
  }
@@ -914,6 +1534,7 @@ var SmtpConnection = class {
914
1534
  reject(/* @__PURE__ */ new Error("STARTTLS upgrade timeout"));
915
1535
  }, this.config.connectionTimeout);
916
1536
  const plainSocket = this.socket;
1537
+ plainSocket.setTimeout(0);
917
1538
  const tlsSocket = (0, node_tls.connect)({
918
1539
  socket: plainSocket,
919
1540
  host: this.config.host,
@@ -926,7 +1547,9 @@ var SmtpConnection = class {
926
1547
  });
927
1548
  const onSecureConnect = () => {
928
1549
  clearTimeout(timeout);
1550
+ tlsSocket.off("error", onError);
929
1551
  this.socket = tlsSocket;
1552
+ this.observeSocket(tlsSocket);
930
1553
  this.socket.setTimeout(this.config.socketTimeout);
931
1554
  resolve();
932
1555
  };
@@ -937,11 +1560,6 @@ var SmtpConnection = class {
937
1560
  };
938
1561
  tlsSocket.once("secureConnect", onSecureConnect);
939
1562
  tlsSocket.once("error", onError);
940
- tlsSocket.once("timeout", () => {
941
- clearTimeout(timeout);
942
- tlsSocket.destroy();
943
- reject(/* @__PURE__ */ new Error("TLS upgrade timeout"));
944
- });
945
1563
  });
946
1564
  }
947
1565
  async authenticate(signal) {
@@ -1055,6 +1673,16 @@ var SmtpConnection = class {
1055
1673
  throw new SmtpAuthResponseError(`${mechanism} authentication failed: ${response.message}`, response.code, `AUTH ${mechanism}`, response.message);
1056
1674
  }
1057
1675
  async sendMessage(message, signal) {
1676
+ this.active = true;
1677
+ this.socket?.setTimeout(0);
1678
+ try {
1679
+ return await this.sendPreparedMessage(message, signal);
1680
+ } finally {
1681
+ this.active = false;
1682
+ if (this.usable) this.socket?.setTimeout(this.config.socketTimeout);
1683
+ }
1684
+ }
1685
+ async sendPreparedMessage(message, signal) {
1058
1686
  signal?.throwIfAborted();
1059
1687
  let smtpUtf8Parameters = "";
1060
1688
  if (message.requiresSmtpUtf8 === true) {
@@ -1064,14 +1692,25 @@ var SmtpConnection = class {
1064
1692
  }
1065
1693
  const sizeCapability = parseSizeCapability(this.capabilities);
1066
1694
  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
1695
  const dsn = message.envelope.dsn;
1073
1696
  if (dsn != null && !this.capabilities.some((capability) => /^DSN[ \t]*$/i.test(capability))) throw new SmtpDsnUnsupportedError();
1074
1697
  const mailDsnParameters = dsn == null || dsn.mailParameters.length === 0 ? "" : ` ${dsn.mailParameters.join(" ")}`;
1698
+ const checkSize = (size, phase = "preflight") => {
1699
+ if (!Number.isSafeInteger(size)) throw new RangeError("Message size exceeds the safe integer range.");
1700
+ if (sizeCapability?.maximum != null && BigInt(size) > sizeCapability.maximum) throw new SmtpMessageSizeError(size, sizeCapability.maximum, phase);
1701
+ };
1702
+ if (!this.socket || !this.usable) throw new TypeError("SMTP connection is closed.");
1703
+ const stream = "raw" in message ? {
1704
+ size: node_buffer.Buffer.byteLength(message.raw) + 2,
1705
+ async *read(signal$1) {
1706
+ signal$1?.throwIfAborted();
1707
+ yield node_buffer.Buffer.from(message.raw + "\r\n");
1708
+ }
1709
+ } : await prepareOnSocket(this.socket, this.config.socketTimeout, (signal$1, progress) => prepareMessageStream(message, checkSize, progress, signal$1), signal);
1710
+ if (stream.size != null) {
1711
+ checkSize(stream.size);
1712
+ if (sizeCapability != null) sizeParameter = ` SIZE=${stream.size}`;
1713
+ }
1075
1714
  const mailCommand = `MAIL FROM:<${message.envelope.from ?? ""}>${sizeParameter}${smtpUtf8Parameters}${mailDsnParameters}`;
1076
1715
  const recipientCommands = message.envelope.to.map((recipient, index) => {
1077
1716
  const parameters = dsn?.recipientParameters[index] ?? [];
@@ -1120,8 +1759,7 @@ var SmtpConnection = class {
1120
1759
  }
1121
1760
  const dataResponse = await this.sendCommand("DATA", signal);
1122
1761
  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);
1762
+ const finalResponse = await writeMessageData(this.socket, (signal$1, progress) => stream.read(signal$1, progress), this.config.socketTimeout, (size) => checkSize(size, "data"), signal);
1125
1763
  if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
1126
1764
  const messageId = this.extractMessageId(finalResponse.message);
1127
1765
  return {
@@ -1271,296 +1909,71 @@ function resolveSmtpEnvelope(message, override) {
1271
1909
  }
1272
1910
 
1273
1911
  //#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);
1912
+ //#region src/mime-stream.ts
1913
+ /** Encodes base64 without retaining producer-owned carry bytes. */
1914
+ async function* encodeAttachment(content, signal, progress) {
1915
+ const carry = new Uint8Array(3);
1916
+ let carried = 0;
1917
+ let column = 0;
1918
+ function wrap(encoded) {
1919
+ const parts = [];
1920
+ let offset = 0;
1921
+ while (offset < encoded.length) {
1922
+ if (column === 76) {
1923
+ parts.push("\r\n");
1924
+ column = 0;
1925
+ }
1926
+ const take = Math.min(76 - column, encoded.length - offset);
1927
+ parts.push(encoded.slice(offset, offset + take));
1928
+ offset += take;
1929
+ column += take;
1459
1930
  }
1931
+ return node_buffer.Buffer.from(parts.join(""));
1460
1932
  }
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);
1933
+ let processed = 0;
1934
+ for await (const chunk of (0, __upyo_core.iterateAttachmentContent)(content, signal)) {
1935
+ if (chunk.length > 0) progress?.();
1936
+ let offset = 0;
1937
+ if (carried > 0) {
1938
+ while (carried < 3 && offset < chunk.length) carry[carried++] = chunk[offset++];
1939
+ if (carried === 3) {
1940
+ yield wrap(node_buffer.Buffer.from(carry).toString("base64"));
1941
+ carried = 0;
1942
+ }
1524
1943
  }
1944
+ while (offset + 3 <= chunk.length) {
1945
+ signal?.throwIfAborted();
1946
+ const length = Math.min(45 * 1024, Math.floor((chunk.length - offset) / 3) * 3);
1947
+ yield wrap(node_buffer.Buffer.from(chunk.buffer, chunk.byteOffset + offset, length).toString("base64"));
1948
+ offset += length;
1949
+ processed += length;
1950
+ if (processed >= 1024 * 1024) {
1951
+ await new Promise((resolve) => setTimeout(resolve, 0));
1952
+ processed = 0;
1953
+ }
1954
+ }
1955
+ while (offset < chunk.length) carry[carried++] = chunk[offset++];
1525
1956
  }
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);
1957
+ if (carried > 0) yield wrap(node_buffer.Buffer.from(carry.subarray(0, carried)).toString("base64"));
1539
1958
  }
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);
1959
+ /** Encoded payload length, excluding MIME framing and a trailing CRLF. */
1960
+ function attachmentEncodedSize(length) {
1961
+ const encoded = 4 * Math.ceil(length / 3);
1962
+ return encoded + (encoded === 0 ? 0 : 2 * Math.floor((encoded - 1) / 76));
1548
1963
  }
1549
1964
 
1550
1965
  //#endregion
1551
1966
  //#region src/message-converter.ts
1552
1967
  /**
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.
1968
+ * Freezes message metadata without reading attachment content.
1969
+ * @param message Message whose metadata will be frozen.
1970
+ * @param dkimConfig Optional signing configuration.
1971
+ * @param dsn Validated delivery-status parameters.
1972
+ * @param resolvedEnvelope Validated effective SMTP envelope.
1973
+ * @returns A deterministic MIME plan for one send attempt.
1974
+ * @throws {RangeError} If a header cannot fit the RFC 5322 line limit.
1562
1975
  */
1563
- async function convertMessage(message, dkimConfig, dsn, resolvedEnvelope = resolveSmtpEnvelope(message)) {
1976
+ function prepareMessage(message, dkimConfig, dsn, resolvedEnvelope = resolveSmtpEnvelope(message)) {
1564
1977
  const envelope = {
1565
1978
  ...resolvedEnvelope,
1566
1979
  dsn
@@ -1573,23 +1986,45 @@ async function convertMessage(message, dkimConfig, dsn, resolvedEnvelope = resol
1573
1986
  ];
1574
1987
  const envelopeAddresses = [...envelope.from == null ? [] : [envelope.from], ...envelope.to];
1575
1988
  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
- }
1989
+ const parts = buildMimeParts(message);
1990
+ const first = parts[0];
1991
+ if (typeof first !== "string") throw new TypeError("Missing MIME headers.");
1992
+ const separator = first.indexOf("\r\n\r\n") + 4;
1993
+ const headers = first.slice(0, separator);
1994
+ parts[0] = first.slice(separator);
1586
1995
  return {
1587
1996
  envelope,
1588
- raw,
1589
- requiresSmtpUtf8
1997
+ requiresSmtpUtf8,
1998
+ dkim: dkimConfig,
1999
+ headers,
2000
+ async *body(signal, progress) {
2001
+ for (const part of parts) {
2002
+ signal?.throwIfAborted();
2003
+ if (typeof part === "string") {
2004
+ const bytes = node_buffer.Buffer.from(part);
2005
+ for (let offset = 0; offset < bytes.length; offset += 65536) yield bytes.subarray(offset, offset + 65536);
2006
+ } else yield* encodeAttachment(part.content, signal, progress);
2007
+ }
2008
+ },
2009
+ async size(signal, checkSize) {
2010
+ let size = node_buffer.Buffer.byteLength(headers);
2011
+ let unknown = false;
2012
+ for (const part of parts) {
2013
+ signal?.throwIfAborted();
2014
+ if (typeof part === "string") size += node_buffer.Buffer.byteLength(part);
2015
+ else {
2016
+ if (part.content instanceof Promise) part.content = await (0, __upyo_core.readAttachmentContent)(part.content, signal);
2017
+ if (typeof part.content === "function") unknown = true;
2018
+ else size += attachmentEncodedSize(part.content instanceof Uint8Array ? part.content.byteLength : part.content.size);
2019
+ }
2020
+ if (!Number.isSafeInteger(size)) throw new RangeError("Message size exceeds the safe integer range.");
2021
+ }
2022
+ checkSize?.(size);
2023
+ return unknown ? void 0 : size;
2024
+ }
1590
2025
  };
1591
2026
  }
1592
- async function buildRawMessage(message) {
2027
+ function buildMimeParts(message) {
1593
2028
  const lines = [];
1594
2029
  const boundary = generateBoundary();
1595
2030
  const hasAttachments = message.attachments.length > 0;
@@ -1654,7 +2089,7 @@ async function buildRawMessage(message) {
1654
2089
  lines.push(`Content-ID: <${attachment.contentId}>`);
1655
2090
  } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1656
2091
  lines.push("");
1657
- lines.push(encodeBase64(await attachment.content));
2092
+ lines.push({ content: attachment.content });
1658
2093
  }
1659
2094
  lines.push("");
1660
2095
  lines.push(`--${boundary}--`);
@@ -1669,7 +2104,18 @@ async function buildRawMessage(message) {
1669
2104
  lines.push("");
1670
2105
  lines.push(encodeQuotedPrintable(message.content.text));
1671
2106
  }
1672
- return lines.join("\r\n");
2107
+ const parts = [];
2108
+ let text = "";
2109
+ for (const line of lines) {
2110
+ if (typeof line === "string") text += line;
2111
+ else {
2112
+ parts.push(text, line);
2113
+ text = "";
2114
+ }
2115
+ text += "\r\n";
2116
+ }
2117
+ parts.push(text);
2118
+ return parts;
1673
2119
  }
1674
2120
  function generateBoundary() {
1675
2121
  return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -1784,10 +2230,6 @@ function encodeQuotedPrintable(text) {
1784
2230
  }
1785
2231
  return result;
1786
2232
  }
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
2233
 
1792
2234
  //#endregion
1793
2235
  //#region src/smtp-transport.ts
@@ -1849,6 +2291,7 @@ var SmtpTransport = class {
1849
2291
  * and options.
1850
2292
  */
1851
2293
  constructor(config) {
2294
+ validateDkimBodyMode(config.dkim);
1852
2295
  this.config = config;
1853
2296
  this.poolSize = config.poolSize ?? 5;
1854
2297
  const auth = config.auth;
@@ -1890,7 +2333,7 @@ var SmtpTransport = class {
1890
2333
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1891
2334
  connection = await this.getConnection(options?.signal);
1892
2335
  options?.signal?.throwIfAborted();
1893
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2336
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1894
2337
  options?.signal?.throwIfAborted();
1895
2338
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1896
2339
  await this.returnConnection(connection);
@@ -1901,7 +2344,7 @@ var SmtpTransport = class {
1901
2344
  rejectedRecipients: result.rejectedRecipients
1902
2345
  };
1903
2346
  } catch (error) {
1904
- if (connection != null) if (isReusableLocalFailure(error)) await this.returnConnection(connection);
2347
+ if (connection != null) if (connection.usable && isReusableLocalFailure(error)) await this.returnConnection(connection);
1905
2348
  else await this.discardConnection(connection);
1906
2349
  options?.signal?.throwIfAborted();
1907
2350
  return createSmtpFailure(error instanceof Error ? error.message : String(error), error);
@@ -1965,7 +2408,7 @@ var SmtpTransport = class {
1965
2408
  try {
1966
2409
  const envelope = resolveEnvelopeOption(message, options?.envelope, index++);
1967
2410
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1968
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2411
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1969
2412
  options?.signal?.throwIfAborted();
1970
2413
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1971
2414
  yield {
@@ -1976,7 +2419,7 @@ var SmtpTransport = class {
1976
2419
  };
1977
2420
  } catch (error) {
1978
2421
  options?.signal?.throwIfAborted();
1979
- if (!isReusableLocalFailure(error)) connectionValid = false;
2422
+ if (!connection.usable || !isReusableLocalFailure(error)) connectionValid = false;
1980
2423
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1981
2424
  }
1982
2425
  }
@@ -1991,7 +2434,7 @@ var SmtpTransport = class {
1991
2434
  try {
1992
2435
  const envelope = resolveEnvelopeOption(message, options?.envelope, index++);
1993
2436
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1994
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2437
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1995
2438
  options?.signal?.throwIfAborted();
1996
2439
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1997
2440
  yield {
@@ -2002,7 +2445,7 @@ var SmtpTransport = class {
2002
2445
  };
2003
2446
  } catch (error) {
2004
2447
  options?.signal?.throwIfAborted();
2005
- if (!isReusableLocalFailure(error)) connectionValid = false;
2448
+ if (!connection.usable || !isReusableLocalFailure(error)) connectionValid = false;
2006
2449
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
2007
2450
  }
2008
2451
  }
@@ -2016,7 +2459,11 @@ var SmtpTransport = class {
2016
2459
  }
2017
2460
  async getConnection(signal) {
2018
2461
  signal?.throwIfAborted();
2019
- if (this.connectionPool.length > 0) return this.connectionPool.pop();
2462
+ while (this.connectionPool.length > 0) {
2463
+ const connection$1 = this.connectionPool.pop();
2464
+ if (connection$1.usable) return connection$1;
2465
+ await this.discardConnection(connection$1);
2466
+ }
2020
2467
  const connection = new SmtpConnection(this.config, this.tokenManager);
2021
2468
  try {
2022
2469
  await this.connectAndSetup(connection, signal);
@@ -2044,7 +2491,7 @@ var SmtpTransport = class {
2044
2491
  await connection.authenticate(signal);
2045
2492
  }
2046
2493
  async returnConnection(connection) {
2047
- if (!connection.config.pool) {
2494
+ if (!connection.usable || !connection.config.pool) {
2048
2495
  await connection.quit();
2049
2496
  return;
2050
2497
  }
@@ -2119,6 +2566,13 @@ function createSmtpFailure(message, error) {
2119
2566
  retryable: false,
2120
2567
  attempts: 1
2121
2568
  });
2569
+ if (error instanceof SmtpAttachmentReplayError) return (0, __upyo_core.createFailedReceipt)(message, {
2570
+ provider: "smtp",
2571
+ code: "smtp.attachment-replay-mismatch",
2572
+ category: "validation",
2573
+ retryable: false,
2574
+ attempts: 1
2575
+ });
2122
2576
  if (error instanceof SmtpMessageSizeError) return (0, __upyo_core.createFailedReceipt)(message, {
2123
2577
  provider: "smtp",
2124
2578
  code: "smtp.message-size-exceeded",
@@ -2161,7 +2615,7 @@ function createSmtpFailure(message, error) {
2161
2615
  });
2162
2616
  }
2163
2617
  function isReusableLocalFailure(error) {
2164
- return error instanceof SmtpMessageSizeError || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpEnvelopeValidationError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2618
+ return error instanceof SmtpMessageSizeError && error.phase === "preflight" || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpEnvelopeValidationError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2165
2619
  }
2166
2620
  function resolveEnvelopeOption(message, option, index) {
2167
2621
  let override;
@@ -2213,6 +2667,7 @@ function isSmtpResponseProviderDetails(value) {
2213
2667
  }
2214
2668
 
2215
2669
  //#endregion
2670
+ exports.SmtpAttachmentReplayError = SmtpAttachmentReplayError;
2216
2671
  exports.SmtpAuthError = SmtpAuthError;
2217
2672
  exports.SmtpDsnUnsupportedError = SmtpDsnUnsupportedError;
2218
2673
  exports.SmtpDsnValidationError = SmtpDsnValidationError;