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