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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,541 @@ 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
+ for (const byte of bytes) {
501
+ if (this.cr) {
502
+ this.cr = false;
503
+ if (byte === 10) {
504
+ this.whitespace = false;
505
+ this.newlines++;
506
+ continue;
507
+ }
508
+ this.content(13);
509
+ }
510
+ if (byte === 13) this.cr = true;
511
+ else this.content(byte);
512
+ }
513
+ }
514
+ content(byte) {
515
+ if (this.mode === "relaxed" && (byte === 32 || byte === 9)) {
516
+ this.whitespace = true;
517
+ return;
518
+ }
519
+ while (this.newlines > 0) {
520
+ this.emit(13);
521
+ this.emit(10);
522
+ this.newlines--;
523
+ }
524
+ if (this.whitespace) {
525
+ this.emit(32);
526
+ this.whitespace = false;
527
+ }
528
+ this.emit(byte);
529
+ this.nonempty = true;
530
+ }
531
+ emit(byte) {
532
+ this.buffer[this.used++] = byte;
533
+ if (this.used === this.buffer.length) {
534
+ this.hash.update(this.buffer);
535
+ this.used = 0;
536
+ }
537
+ }
538
+ digest() {
539
+ if (this.cr) this.content(13);
540
+ if (this.nonempty || this.mode === "simple") {
541
+ this.emit(13);
542
+ this.emit(10);
543
+ }
544
+ this.hash.update(this.buffer.subarray(0, this.used));
545
+ return this.hash.digest("base64");
546
+ }
547
+ };
548
+
549
+ //#endregion
550
+ //#region src/dkim/canonicalize.ts
551
+ /**
552
+ * DKIM Canonicalization algorithms per RFC 6376 Section 3.4.
553
+ *
554
+ * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4
555
+ * @since 0.4.0
556
+ */
557
+ /**
558
+ * Simple header canonicalization.
559
+ *
560
+ * The "simple" header canonicalization algorithm does not change header
561
+ * fields in any way. Header fields are presented to the signing or
562
+ * verification algorithm exactly as they are in the message.
563
+ *
564
+ * @param name - The header field name
565
+ * @param value - The header field value
566
+ * @returns The canonicalized header line (name:value)
567
+ * @see RFC 6376 Section 3.4.1
568
+ * @since 0.4.0
569
+ */
570
+ function canonicalizeHeaderSimple(name, value) {
571
+ return `${name}:${value}`;
572
+ }
573
+ /**
574
+ * Relaxed header canonicalization.
575
+ *
576
+ * The "relaxed" header canonicalization algorithm:
577
+ * - Convert header field names to lowercase
578
+ * - Unfold header field continuation lines
579
+ * - Collapse whitespace sequences to a single space
580
+ * - Remove leading and trailing whitespace from header field values
581
+ *
582
+ * @param name - The header field name
583
+ * @param value - The header field value
584
+ * @returns The canonicalized header line (name:value)
585
+ * @see RFC 6376 Section 3.4.2
586
+ * @since 0.4.0
587
+ */
588
+ function canonicalizeHeaderRelaxed(name, value) {
589
+ const canonicalName = name.toLowerCase();
590
+ const canonicalValue = value.replace(/\r\n[\t ]+/g, " ").replace(/[\t ]+/g, " ").trim();
591
+ return `${canonicalName}:${canonicalValue}`;
592
+ }
593
+
594
+ //#endregion
595
+ //#region src/dkim/sign.ts
596
+ /**
597
+ * Signs frozen headers using a body hash computed by the MIME reader.
598
+ * @param rawHeaders Frozen wire headers, including earlier signatures.
599
+ * @param config Signature configuration.
600
+ * @param bodyHash Canonical body SHA-256 digest, encoded as base64.
601
+ * @param signal Optional cancellation signal.
602
+ * @returns A DKIM-Signature header value.
603
+ * @throws {Error} If key import, signing, or cancellation fails.
604
+ */
605
+ async function signWithBodyHash(rawHeaders, config, bodyHash, signal) {
606
+ signal?.throwIfAborted();
607
+ const algorithm = config.algorithm ?? DEFAULT_ALGORITHM;
608
+ const canonicalization = config.canonicalization ?? DEFAULT_CANONICALIZATION;
609
+ const headerFields = config.headerFields ?? DEFAULT_SIGNED_HEADERS;
610
+ const { headers } = parseMessage(rawHeaders);
611
+ const [headerCanon] = canonicalization.split("/");
612
+ const privateKey = await getPrivateKey(config.privateKey, algorithm);
613
+ signal?.throwIfAborted();
614
+ const dkimHeaderValue = buildDkimHeaderValue({
615
+ algorithm,
616
+ canonicalization,
617
+ signingDomain: config.signingDomain,
618
+ selector: config.selector,
619
+ headerFields,
620
+ bodyHash
621
+ });
622
+ const signatureData = buildSignatureData(headers, headerFields, headerCanon, dkimHeaderValue);
623
+ const signature = await signData(signatureData, privateKey, algorithm);
624
+ signal?.throwIfAborted();
625
+ return {
626
+ headerName: "DKIM-Signature",
627
+ signature: `${dkimHeaderValue} b=${signature}`
628
+ };
629
+ }
630
+ /**
631
+ * Parses a raw email message into headers and body.
632
+ */
633
+ function parseMessage(rawMessage) {
634
+ const separatorIndex = rawMessage.indexOf("\r\n\r\n");
635
+ if (separatorIndex === -1) return {
636
+ headers: parseHeaders(rawMessage),
637
+ body: ""
638
+ };
639
+ const headerSection = rawMessage.substring(0, separatorIndex);
640
+ const body = rawMessage.substring(separatorIndex + 4);
641
+ return {
642
+ headers: parseHeaders(headerSection),
643
+ body
644
+ };
645
+ }
646
+ /**
647
+ * Parses header section into a map of header name to value.
648
+ * Handles folded headers (continuation lines).
649
+ */
650
+ function parseHeaders(headerSection) {
651
+ const headers = /* @__PURE__ */ new Map();
652
+ const lines = headerSection.split("\r\n");
653
+ let currentName = "";
654
+ let currentValue = "";
655
+ for (const line of lines) if (line.startsWith(" ") || line.startsWith(" ")) currentValue += "\r\n" + line;
656
+ else {
657
+ if (currentName) headers.set(currentName.toLowerCase(), {
658
+ name: currentName,
659
+ value: currentValue
660
+ });
661
+ const colonIndex = line.indexOf(":");
662
+ if (colonIndex > 0) {
663
+ currentName = line.substring(0, colonIndex);
664
+ currentValue = line.substring(colonIndex + 1);
665
+ }
666
+ }
667
+ if (currentName) headers.set(currentName.toLowerCase(), {
668
+ name: currentName,
669
+ value: currentValue
670
+ });
671
+ return headers;
672
+ }
673
+ /**
674
+ * Gets the private key, either using a provided CryptoKey or importing from PEM.
675
+ */
676
+ function getPrivateKey(key, algorithm) {
677
+ if (typeof key !== "string") return key;
678
+ return importPrivateKey(key, algorithm);
679
+ }
680
+ /**
681
+ * Imports a PEM-encoded private key for use with Web Crypto API.
682
+ */
683
+ async function importPrivateKey(pem, algorithm) {
684
+ try {
685
+ const pemContents = pem.replace(/-----BEGIN (?:RSA )?PRIVATE KEY-----/, "").replace(/-----END (?:RSA )?PRIVATE KEY-----/, "").replace(/\s/g, "");
686
+ const binaryString = atob(pemContents);
687
+ const bytes = new Uint8Array(binaryString.length);
688
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
689
+ const keyAlgorithm = algorithm === "ed25519-sha256" ? { name: "Ed25519" } : {
690
+ name: "RSASSA-PKCS1-v1_5",
691
+ hash: "SHA-256"
692
+ };
693
+ return await crypto.subtle.importKey("pkcs8", bytes, keyAlgorithm, false, ["sign"]);
694
+ } catch (error) {
695
+ throw new Error(`Failed to import private key: ${error instanceof Error ? error.message : String(error)}`);
696
+ }
697
+ }
698
+ /**
699
+ * Builds the DKIM-Signature header value without the b= signature.
700
+ */
701
+ function buildDkimHeaderValue(params) {
702
+ const parts = [
703
+ "v=1",
704
+ `a=${params.algorithm}`,
705
+ `c=${params.canonicalization}`,
706
+ `d=${params.signingDomain}`,
707
+ `s=${params.selector}`,
708
+ `h=${params.headerFields.join(":")}`,
709
+ `bh=${params.bodyHash};`
710
+ ];
711
+ return parts.join("; ");
712
+ }
713
+ /**
714
+ * Builds the data to be signed (canonicalized headers + DKIM-Signature header).
715
+ */
716
+ function buildSignatureData(headers, headerFields, canonMethod, dkimHeaderValue) {
717
+ const lines = [];
718
+ for (const field of headerFields) {
719
+ const header = headers.get(field.toLowerCase());
720
+ if (header !== void 0) {
721
+ const canonicalized = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed(header.name, header.value) : canonicalizeHeaderSimple(header.name, header.value);
722
+ lines.push(canonicalized);
723
+ }
724
+ }
725
+ const dkimHeader = canonMethod === "relaxed" ? canonicalizeHeaderRelaxed("DKIM-Signature", " " + dkimHeaderValue + " b=") : canonicalizeHeaderSimple("DKIM-Signature", " " + dkimHeaderValue + " b=");
726
+ lines.push(dkimHeader);
727
+ return lines.join("\r\n");
728
+ }
729
+ /**
730
+ * Signs data using the appropriate algorithm.
731
+ */
732
+ async function signData(data, privateKey, algorithm) {
733
+ const encoder = new TextEncoder();
734
+ const dataBuffer = encoder.encode(data);
735
+ const signAlgorithm = algorithm === "ed25519-sha256" ? "Ed25519" : "RSASSA-PKCS1-v1_5";
736
+ const signingInput = algorithm === "ed25519-sha256" ? await crypto.subtle.digest("SHA-256", dataBuffer) : dataBuffer;
737
+ const signature = await crypto.subtle.sign(signAlgorithm, privateKey, signingInput);
738
+ return arrayBufferToBase64(signature);
739
+ }
740
+ /**
741
+ * Converts an ArrayBuffer to a Base64 string.
742
+ */
743
+ function arrayBufferToBase64(buffer) {
744
+ const bytes = new Uint8Array(buffer);
745
+ let binary = "";
746
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
747
+ return btoa(binary);
748
+ }
749
+
750
+ //#endregion
751
+ //#region src/message-stream.ts
752
+ /**
753
+ * A replayable attachment changed between DKIM body reads.
754
+ * @since 0.6.0
755
+ */
756
+ var SmtpAttachmentReplayError = class extends TypeError {
757
+ /** Creates a replay validation failure. */
758
+ constructor() {
759
+ super("Attachment content changed between DKIM body reads.");
760
+ this.name = "SmtpAttachmentReplayError";
761
+ }
762
+ };
763
+ /** Prepares signatures and sizes without consuming unsigned factory sources. */
764
+ async function prepareMessageStream(plan, checkSize, progress, signal) {
765
+ const knownSize = await plan.size(signal, checkSize);
766
+ if (knownSize != null) checkSize(knownSize);
767
+ const signatures = plan.dkim?.signatures ?? [];
768
+ if (signatures.length === 0) return {
769
+ size: knownSize,
770
+ async *read(signal$1, progress$1) {
771
+ yield Buffer.from(plan.headers);
772
+ yield* plan.body(signal$1, progress$1);
773
+ }
774
+ };
775
+ const buffered = plan.dkim?.bodyMode !== "streaming";
776
+ const chunks = [];
777
+ const hashes = /* @__PURE__ */ new Map();
778
+ for (const sig of signatures) {
779
+ const mode = sig.canonicalization?.endsWith("/simple") ? "simple" : "relaxed";
780
+ if (!hashes.has(mode)) hashes.set(mode, new BodyHasher(mode));
781
+ }
782
+ const rawHash = createHash("sha256");
783
+ let length = 0;
784
+ const headerLength = Buffer.byteLength(plan.headers);
785
+ for await (const chunk of plan.body(signal, progress)) {
786
+ length += chunk.length;
787
+ checkSize(headerLength + length);
788
+ if (chunk.length > 0) progress();
789
+ if (buffered) chunks.push(chunk.slice());
790
+ rawHash.update(chunk);
791
+ for (const hash of hashes.values()) hash.update(chunk);
792
+ }
793
+ const expectedDigest = rawHash.digest("hex");
794
+ const bodyHashes = new Map(Array.from(hashes, ([mode, hash]) => [mode, hash.digest()]));
795
+ let headers = plan.headers;
796
+ try {
797
+ for (const sig of signatures) {
798
+ const mode = sig.canonicalization?.endsWith("/simple") ? "simple" : "relaxed";
799
+ const result = await signWithBodyHash(headers, sig, bodyHashes.get(mode), signal);
800
+ headers = `${result.headerName}: ${result.signature}\r\n${headers}`;
801
+ }
802
+ } catch (error) {
803
+ signal?.throwIfAborted();
804
+ if (plan.dkim?.onSigningFailure !== "send-unsigned") throw error;
805
+ console.warn("DKIM signing failed, sending unsigned:", error);
806
+ }
807
+ const size = Buffer.byteLength(headers) + length;
808
+ checkSize(size);
809
+ return {
810
+ size,
811
+ async *read(signal$1, progress$1) {
812
+ yield Buffer.from(headers);
813
+ if (buffered) {
814
+ for (const chunk of chunks) {
815
+ signal$1?.throwIfAborted();
816
+ yield chunk;
817
+ }
818
+ return;
819
+ }
820
+ const replayHash = createHash("sha256");
821
+ let replayLength = 0;
822
+ for await (const chunk of plan.body(signal$1, progress$1)) {
823
+ replayLength += chunk.length;
824
+ if (replayLength > length) throw new SmtpAttachmentReplayError();
825
+ replayHash.update(chunk);
826
+ yield chunk;
827
+ }
828
+ if (replayLength !== length || replayHash.digest("hex") !== expectedDigest) throw new SmtpAttachmentReplayError();
829
+ }
830
+ };
831
+ }
832
+
833
+ //#endregion
834
+ //#region src/data-stream.ts
835
+ /** Races preparation against source inactivity and remote socket termination. */
836
+ async function prepareOnSocket(socket, timeoutMs, prepare, signal) {
837
+ const controller = new AbortController();
838
+ const combined = combineSignals(controller.signal, signal);
839
+ const fail = (error) => controller.abort(error);
840
+ const closed = () => fail(/* @__PURE__ */ new TypeError("SMTP connection closed during message preparation."));
841
+ const data = () => fail(/* @__PURE__ */ new TypeError("Unexpected SMTP reply during message preparation."));
842
+ let timer;
843
+ const progress = () => {
844
+ clearTimeout(timer);
845
+ timer = setTimeout(() => fail(/* @__PURE__ */ new TypeError("SMTP message preparation timeout.")), timeoutMs);
846
+ };
847
+ socket.on("error", fail);
848
+ socket.on("close", closed);
849
+ socket.on("data", data);
850
+ progress();
851
+ try {
852
+ if (socket.destroyed || !socket.writable) closed();
853
+ combined.signal.throwIfAborted();
854
+ return await abortable(prepare(combined.signal, progress), combined.signal);
855
+ } catch (error) {
856
+ const interrupted = combined.signal.aborted;
857
+ controller.abort(error);
858
+ if (interrupted) socket.destroy();
859
+ signal?.throwIfAborted();
860
+ throw error;
861
+ } finally {
862
+ clearTimeout(timer);
863
+ socket.off("error", fail);
864
+ socket.off("close", closed);
865
+ socket.off("data", data);
866
+ combined.cleanup();
867
+ }
868
+ }
869
+ function abortable(promise, signal) {
870
+ return new Promise((resolve, reject) => {
871
+ const abort = () => reject(signal.reason);
872
+ signal.addEventListener("abort", abort, { once: true });
873
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
874
+ if (signal.aborted) abort();
875
+ });
876
+ }
877
+ /** Writes one DATA body with bounded transparency buffers and backpressure. */
878
+ async function writeMessageData(socket, source, timeoutMs, checkSize, signal) {
879
+ const controller = new AbortController();
880
+ const combined = combineSignals(controller.signal, signal);
881
+ const owned = combined.signal;
882
+ let terminated = false;
883
+ let complete = false;
884
+ let replyBytes = 0;
885
+ let buffer = "";
886
+ const lines = [];
887
+ let resolveReply;
888
+ const reply = new Promise((resolve) => resolveReply = resolve);
889
+ const fail = (error) => {
890
+ if (!owned.aborted) controller.abort(error);
891
+ socket.destroy();
892
+ };
893
+ const close = () => fail(/* @__PURE__ */ new TypeError("SMTP connection closed during DATA."));
894
+ const onAbort = () => socket.destroy();
895
+ let timer;
896
+ const progress = () => {
897
+ clearTimeout(timer);
898
+ timer = setTimeout(() => fail(/* @__PURE__ */ new TypeError("SMTP DATA timeout.")), timeoutMs);
899
+ };
900
+ const data = (chunk) => {
901
+ replyBytes += chunk.length;
902
+ if (replyBytes > 65536) {
903
+ fail(/* @__PURE__ */ new RangeError("SMTP DATA reply exceeds 64 KiB."));
904
+ return;
905
+ }
906
+ buffer += Buffer.from(chunk).toString("utf8");
907
+ let end;
908
+ while ((end = buffer.indexOf("\r\n")) >= 0) {
909
+ const line = buffer.slice(0, end);
910
+ buffer = buffer.slice(end + 2);
911
+ lines.push(line);
912
+ if (/^\d{3} /.test(line)) {
913
+ if (!terminated) fail(/* @__PURE__ */ new TypeError(`Premature SMTP DATA reply: ${line}`));
914
+ else resolveReply({
915
+ code: Number(line.slice(0, 3)),
916
+ message: line.slice(4),
917
+ raw: lines.join("\r\n")
918
+ });
919
+ return;
920
+ }
921
+ }
922
+ };
923
+ socket.on("error", fail);
924
+ socket.on("close", close);
925
+ socket.on("data", data);
926
+ owned.addEventListener("abort", onAbort, { once: true });
927
+ progress();
928
+ let iterator;
929
+ async function write(bytes) {
930
+ owned.throwIfAborted();
931
+ await abortable(new Promise((resolve, reject) => {
932
+ let written = false;
933
+ let drained = false;
934
+ let returned = false;
935
+ const cleanup = () => {
936
+ socket.off("drain", drain);
937
+ owned.removeEventListener("abort", cleanup);
938
+ };
939
+ const finish = () => {
940
+ if (returned && written && drained) {
941
+ cleanup();
942
+ progress();
943
+ resolve();
944
+ }
945
+ };
946
+ const drain = () => {
947
+ drained = true;
948
+ finish();
949
+ };
950
+ socket.once("drain", drain);
951
+ owned.addEventListener("abort", cleanup, { once: true });
952
+ try {
953
+ const accepted = socket.write(bytes, (error) => {
954
+ if (error != null) {
955
+ cleanup();
956
+ reject(error);
957
+ return;
958
+ }
959
+ written = true;
960
+ finish();
961
+ });
962
+ drained = accepted || drained;
963
+ returned = true;
964
+ finish();
965
+ } catch (error) {
966
+ cleanup();
967
+ reject(error);
968
+ }
969
+ }), owned);
970
+ }
971
+ try {
972
+ iterator = source(owned, progress)[Symbol.asyncIterator]();
973
+ if (socket.destroyed || !socket.writable) close();
974
+ let lineStart = true;
975
+ let size = 0;
976
+ while (true) {
977
+ owned.throwIfAborted();
978
+ const item = await abortable(Promise.resolve(iterator.next()), owned);
979
+ if (item.done) break;
980
+ const chunk = item.value;
981
+ for (let offset = 0; offset < chunk.length; offset += 32768) {
982
+ const window = chunk.subarray(offset, offset + 32768);
983
+ size += window.length;
984
+ checkSize(size);
985
+ progress();
986
+ const output = Buffer.allocUnsafe(window.length * 2);
987
+ let length = 0;
988
+ for (const byte of window) {
989
+ if (lineStart && byte === 46) output[length++] = 46;
990
+ output[length++] = byte;
991
+ lineStart = byte === 10;
992
+ }
993
+ await write(output.subarray(0, length));
994
+ }
995
+ }
996
+ owned.throwIfAborted();
997
+ terminated = true;
998
+ await write(Buffer.from(".\r\n"));
999
+ const result = await abortable(reply, owned);
1000
+ owned.throwIfAborted();
1001
+ complete = true;
1002
+ return result;
1003
+ } catch (error) {
1004
+ fail(error);
1005
+ signal?.throwIfAborted();
1006
+ throw owned.reason;
1007
+ } finally {
1008
+ if (!complete) try {
1009
+ Promise.resolve(iterator?.return?.()).catch(() => {});
1010
+ } catch {}
1011
+ clearTimeout(timer);
1012
+ socket.off("error", fail);
1013
+ socket.off("close", close);
1014
+ socket.off("data", data);
1015
+ owned.removeEventListener("abort", onAbort);
1016
+ combined.cleanup();
1017
+ }
1018
+ }
1019
+
449
1020
  //#endregion
450
1021
  //#region src/smtp-status-code.ts
451
1022
  /**
@@ -480,13 +1051,13 @@ function parseEnhancedSmtpStatusCode(replyCode, response) {
480
1051
  * Error thrown when a message exceeds the fixed limit advertised through the
481
1052
  * SMTP SIZE extension.
482
1053
  *
483
- * The check happens before `MAIL FROM`, so the SMTP connection remains usable
484
- * for another message.
1054
+ * Known sizes are checked before `MAIL FROM`. Unknown source sizes are also
1055
+ * checked while writing DATA; those failures make the connection unusable.
485
1056
  *
486
1057
  * @since 0.6.0
487
1058
  */
488
1059
  var SmtpMessageSizeError = class extends RangeError {
489
- /** The encoded message size in octets. */
1060
+ /** Exact known size, or the octets counted when an unknown source is stopped. */
490
1061
  actualSize;
491
1062
  /** The fixed maximum advertised by the SMTP server. */
492
1063
  maximumSize;
@@ -495,9 +1066,11 @@ var SmtpMessageSizeError = class extends RangeError {
495
1066
  *
496
1067
  * @param actualSize The encoded message size in octets.
497
1068
  * @param maximumSize The fixed maximum advertised by the SMTP server.
1069
+ * @param phase Whether the failure occurred before or during DATA.
498
1070
  */
499
- constructor(actualSize, maximumSize) {
1071
+ constructor(actualSize, maximumSize, phase = "preflight") {
500
1072
  super(`Message size ${actualSize} octets exceeds the server's maximum of ${maximumSize} octets.`);
1073
+ this.phase = phase;
501
1074
  this.name = "SmtpMessageSizeError";
502
1075
  this.actualSize = actualSize;
503
1076
  this.maximumSize = maximumSize;
@@ -692,6 +1265,16 @@ var SmtpConnection = class {
692
1265
  authenticated = false;
693
1266
  capabilities = [];
694
1267
  tokenManager;
1268
+ active = false;
1269
+ get usable() {
1270
+ return this.socket != null && !this.socket.destroyed && this.socket.writable;
1271
+ }
1272
+ observeSocket(socket) {
1273
+ socket.on("error", () => socket.destroy());
1274
+ socket.on("timeout", () => {
1275
+ if (!this.active) socket.destroy();
1276
+ });
1277
+ }
695
1278
  constructor(config, tokenManager) {
696
1279
  this.config = createSmtpConfig(config);
697
1280
  this.tokenManager = tokenManager ?? null;
@@ -701,17 +1284,31 @@ var SmtpConnection = class {
701
1284
  signal?.throwIfAborted();
702
1285
  return new Promise((resolve, reject) => {
703
1286
  const timeout = setTimeout(() => {
1287
+ onError(/* @__PURE__ */ new TypeError("Connection timeout."));
704
1288
  this.socket?.destroy();
705
- reject(/* @__PURE__ */ new Error("Connection timeout"));
706
1289
  }, this.config.connectionTimeout);
707
- const onConnect = () => {
1290
+ const cleanup = () => {
708
1291
  clearTimeout(timeout);
1292
+ this.socket?.off("connect", onConnect);
1293
+ this.socket?.off("error", onError);
1294
+ this.socket?.off("close", onClose);
1295
+ this.socket?.off("timeout", onTimeout);
1296
+ };
1297
+ const onConnect = () => {
1298
+ cleanup();
709
1299
  resolve();
710
1300
  };
711
1301
  const onError = (error) => {
712
- clearTimeout(timeout);
1302
+ cleanup();
713
1303
  reject(error);
714
1304
  };
1305
+ const onClose = () => {
1306
+ onError(/* @__PURE__ */ new TypeError("SMTP connection closed before establishment."));
1307
+ };
1308
+ const onTimeout = () => {
1309
+ onError(/* @__PURE__ */ new TypeError("Socket timeout."));
1310
+ this.socket?.destroy();
1311
+ };
715
1312
  if (this.config.secure) this.socket = connect({
716
1313
  host: this.config.host,
717
1314
  port: this.config.port,
@@ -727,13 +1324,11 @@ var SmtpConnection = class {
727
1324
  this.socket.connect(this.config.port, this.config.host);
728
1325
  }
729
1326
  this.socket.setTimeout(this.config.socketTimeout);
1327
+ this.observeSocket(this.socket);
730
1328
  this.socket.once("connect", onConnect);
731
1329
  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
- });
1330
+ this.socket.once("close", onClose);
1331
+ this.socket.once("timeout", onTimeout);
737
1332
  });
738
1333
  }
739
1334
  sendCommand(command, signal) {
@@ -766,7 +1361,7 @@ var SmtpConnection = class {
766
1361
  buffer += data.toString();
767
1362
  const lines = buffer.split("\r\n");
768
1363
  const incompleteLine = lines.pop() || "";
769
- for (const line of lines) {
1364
+ for (const [lineIndex, line] of lines.entries()) {
770
1365
  responseLines.push(line);
771
1366
  if (line.length >= 4 && line[3] === " ") {
772
1367
  const code = parseInt(line.substring(0, 3), 10);
@@ -781,6 +1376,11 @@ var SmtpConnection = class {
781
1376
  responseLines = [];
782
1377
  if (responses.length === commands.length) {
783
1378
  cleanup();
1379
+ if (commands[0] === "DATA" && (lineIndex + 1 < lines.length || incompleteLine.length > 0)) {
1380
+ this.socket?.destroy();
1381
+ reject(/* @__PURE__ */ new TypeError("Premature SMTP reply after DATA readiness."));
1382
+ return;
1383
+ }
784
1384
  resolve(responses);
785
1385
  return;
786
1386
  }
@@ -891,6 +1491,7 @@ var SmtpConnection = class {
891
1491
  reject(/* @__PURE__ */ new Error("STARTTLS upgrade timeout"));
892
1492
  }, this.config.connectionTimeout);
893
1493
  const plainSocket = this.socket;
1494
+ plainSocket.setTimeout(0);
894
1495
  const tlsSocket = connect({
895
1496
  socket: plainSocket,
896
1497
  host: this.config.host,
@@ -903,7 +1504,9 @@ var SmtpConnection = class {
903
1504
  });
904
1505
  const onSecureConnect = () => {
905
1506
  clearTimeout(timeout);
1507
+ tlsSocket.off("error", onError);
906
1508
  this.socket = tlsSocket;
1509
+ this.observeSocket(tlsSocket);
907
1510
  this.socket.setTimeout(this.config.socketTimeout);
908
1511
  resolve();
909
1512
  };
@@ -914,11 +1517,6 @@ var SmtpConnection = class {
914
1517
  };
915
1518
  tlsSocket.once("secureConnect", onSecureConnect);
916
1519
  tlsSocket.once("error", onError);
917
- tlsSocket.once("timeout", () => {
918
- clearTimeout(timeout);
919
- tlsSocket.destroy();
920
- reject(/* @__PURE__ */ new Error("TLS upgrade timeout"));
921
- });
922
1520
  });
923
1521
  }
924
1522
  async authenticate(signal) {
@@ -1032,6 +1630,16 @@ var SmtpConnection = class {
1032
1630
  throw new SmtpAuthResponseError(`${mechanism} authentication failed: ${response.message}`, response.code, `AUTH ${mechanism}`, response.message);
1033
1631
  }
1034
1632
  async sendMessage(message, signal) {
1633
+ this.active = true;
1634
+ this.socket?.setTimeout(0);
1635
+ try {
1636
+ return await this.sendPreparedMessage(message, signal);
1637
+ } finally {
1638
+ this.active = false;
1639
+ if (this.usable) this.socket?.setTimeout(this.config.socketTimeout);
1640
+ }
1641
+ }
1642
+ async sendPreparedMessage(message, signal) {
1035
1643
  signal?.throwIfAborted();
1036
1644
  let smtpUtf8Parameters = "";
1037
1645
  if (message.requiresSmtpUtf8 === true) {
@@ -1041,14 +1649,25 @@ var SmtpConnection = class {
1041
1649
  }
1042
1650
  const sizeCapability = parseSizeCapability(this.capabilities);
1043
1651
  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
1652
  const dsn = message.envelope.dsn;
1050
1653
  if (dsn != null && !this.capabilities.some((capability) => /^DSN[ \t]*$/i.test(capability))) throw new SmtpDsnUnsupportedError();
1051
1654
  const mailDsnParameters = dsn == null || dsn.mailParameters.length === 0 ? "" : ` ${dsn.mailParameters.join(" ")}`;
1655
+ const checkSize = (size, phase = "preflight") => {
1656
+ if (!Number.isSafeInteger(size)) throw new RangeError("Message size exceeds the safe integer range.");
1657
+ if (sizeCapability?.maximum != null && BigInt(size) > sizeCapability.maximum) throw new SmtpMessageSizeError(size, sizeCapability.maximum, phase);
1658
+ };
1659
+ if (!this.socket || !this.usable) throw new TypeError("SMTP connection is closed.");
1660
+ const stream = "raw" in message ? {
1661
+ size: Buffer.byteLength(message.raw) + 2,
1662
+ async *read(signal$1) {
1663
+ signal$1?.throwIfAborted();
1664
+ yield Buffer.from(message.raw + "\r\n");
1665
+ }
1666
+ } : await prepareOnSocket(this.socket, this.config.socketTimeout, (signal$1, progress) => prepareMessageStream(message, checkSize, progress, signal$1), signal);
1667
+ if (stream.size != null) {
1668
+ checkSize(stream.size);
1669
+ if (sizeCapability != null) sizeParameter = ` SIZE=${stream.size}`;
1670
+ }
1052
1671
  const mailCommand = `MAIL FROM:<${message.envelope.from ?? ""}>${sizeParameter}${smtpUtf8Parameters}${mailDsnParameters}`;
1053
1672
  const recipientCommands = message.envelope.to.map((recipient, index) => {
1054
1673
  const parameters = dsn?.recipientParameters[index] ?? [];
@@ -1097,8 +1716,7 @@ var SmtpConnection = class {
1097
1716
  }
1098
1717
  const dataResponse = await this.sendCommand("DATA", signal);
1099
1718
  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);
1719
+ const finalResponse = await writeMessageData(this.socket, (signal$1, progress) => stream.read(signal$1, progress), this.config.socketTimeout, (size) => checkSize(size, "data"), signal);
1102
1720
  if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
1103
1721
  const messageId = this.extractMessageId(finalResponse.message);
1104
1722
  return {
@@ -1248,296 +1866,71 @@ function resolveSmtpEnvelope(message, override) {
1248
1866
  }
1249
1867
 
1250
1868
  //#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);
1869
+ //#region src/mime-stream.ts
1870
+ /** Encodes base64 without retaining producer-owned carry bytes. */
1871
+ async function* encodeAttachment(content, signal, progress) {
1872
+ const carry = new Uint8Array(3);
1873
+ let carried = 0;
1874
+ let column = 0;
1875
+ function wrap(encoded) {
1876
+ const parts = [];
1877
+ let offset = 0;
1878
+ while (offset < encoded.length) {
1879
+ if (column === 76) {
1880
+ parts.push("\r\n");
1881
+ column = 0;
1882
+ }
1883
+ const take = Math.min(76 - column, encoded.length - offset);
1884
+ parts.push(encoded.slice(offset, offset + take));
1885
+ offset += take;
1886
+ column += take;
1436
1887
  }
1888
+ return Buffer.from(parts.join(""));
1437
1889
  }
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);
1890
+ let processed = 0;
1891
+ for await (const chunk of iterateAttachmentContent(content, signal)) {
1892
+ if (chunk.length > 0) progress?.();
1893
+ let offset = 0;
1894
+ if (carried > 0) {
1895
+ while (carried < 3 && offset < chunk.length) carry[carried++] = chunk[offset++];
1896
+ if (carried === 3) {
1897
+ yield wrap(Buffer.from(carry).toString("base64"));
1898
+ carried = 0;
1899
+ }
1501
1900
  }
1901
+ while (offset + 3 <= chunk.length) {
1902
+ signal?.throwIfAborted();
1903
+ const length = Math.min(45 * 1024, Math.floor((chunk.length - offset) / 3) * 3);
1904
+ yield wrap(Buffer.from(chunk.buffer, chunk.byteOffset + offset, length).toString("base64"));
1905
+ offset += length;
1906
+ processed += length;
1907
+ if (processed >= 1024 * 1024) {
1908
+ await new Promise((resolve) => setTimeout(resolve, 0));
1909
+ processed = 0;
1910
+ }
1911
+ }
1912
+ while (offset < chunk.length) carry[carried++] = chunk[offset++];
1502
1913
  }
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);
1914
+ if (carried > 0) yield wrap(Buffer.from(carry.subarray(0, carried)).toString("base64"));
1516
1915
  }
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);
1916
+ /** Encoded payload length, excluding MIME framing and a trailing CRLF. */
1917
+ function attachmentEncodedSize(length) {
1918
+ const encoded = 4 * Math.ceil(length / 3);
1919
+ return encoded + (encoded === 0 ? 0 : 2 * Math.floor((encoded - 1) / 76));
1525
1920
  }
1526
1921
 
1527
1922
  //#endregion
1528
1923
  //#region src/message-converter.ts
1529
1924
  /**
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.
1925
+ * Freezes message metadata without reading attachment content.
1926
+ * @param message Message whose metadata will be frozen.
1927
+ * @param dkimConfig Optional signing configuration.
1928
+ * @param dsn Validated delivery-status parameters.
1929
+ * @param resolvedEnvelope Validated effective SMTP envelope.
1930
+ * @returns A deterministic MIME plan for one send attempt.
1931
+ * @throws {RangeError} If a header cannot fit the RFC 5322 line limit.
1539
1932
  */
1540
- async function convertMessage(message, dkimConfig, dsn, resolvedEnvelope = resolveSmtpEnvelope(message)) {
1933
+ function prepareMessage(message, dkimConfig, dsn, resolvedEnvelope = resolveSmtpEnvelope(message)) {
1541
1934
  const envelope = {
1542
1935
  ...resolvedEnvelope,
1543
1936
  dsn
@@ -1550,23 +1943,45 @@ async function convertMessage(message, dkimConfig, dsn, resolvedEnvelope = resol
1550
1943
  ];
1551
1944
  const envelopeAddresses = [...envelope.from == null ? [] : [envelope.from], ...envelope.to];
1552
1945
  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
- }
1946
+ const parts = buildMimeParts(message);
1947
+ const first = parts[0];
1948
+ if (typeof first !== "string") throw new TypeError("Missing MIME headers.");
1949
+ const separator = first.indexOf("\r\n\r\n") + 4;
1950
+ const headers = first.slice(0, separator);
1951
+ parts[0] = first.slice(separator);
1563
1952
  return {
1564
1953
  envelope,
1565
- raw,
1566
- requiresSmtpUtf8
1954
+ requiresSmtpUtf8,
1955
+ dkim: dkimConfig,
1956
+ headers,
1957
+ async *body(signal, progress) {
1958
+ for (const part of parts) {
1959
+ signal?.throwIfAborted();
1960
+ if (typeof part === "string") {
1961
+ const bytes = Buffer.from(part);
1962
+ for (let offset = 0; offset < bytes.length; offset += 65536) yield bytes.subarray(offset, offset + 65536);
1963
+ } else yield* encodeAttachment(part.content, signal, progress);
1964
+ }
1965
+ },
1966
+ async size(signal, checkSize) {
1967
+ let size = Buffer.byteLength(headers);
1968
+ let unknown = false;
1969
+ for (const part of parts) {
1970
+ signal?.throwIfAborted();
1971
+ if (typeof part === "string") size += Buffer.byteLength(part);
1972
+ else {
1973
+ if (part.content instanceof Promise) part.content = await readAttachmentContent(part.content, signal);
1974
+ if (typeof part.content === "function") unknown = true;
1975
+ else size += attachmentEncodedSize(part.content instanceof Uint8Array ? part.content.byteLength : part.content.size);
1976
+ }
1977
+ if (!Number.isSafeInteger(size)) throw new RangeError("Message size exceeds the safe integer range.");
1978
+ }
1979
+ checkSize?.(size);
1980
+ return unknown ? void 0 : size;
1981
+ }
1567
1982
  };
1568
1983
  }
1569
- async function buildRawMessage(message) {
1984
+ function buildMimeParts(message) {
1570
1985
  const lines = [];
1571
1986
  const boundary = generateBoundary();
1572
1987
  const hasAttachments = message.attachments.length > 0;
@@ -1631,7 +2046,7 @@ async function buildRawMessage(message) {
1631
2046
  lines.push(`Content-ID: <${attachment.contentId}>`);
1632
2047
  } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1633
2048
  lines.push("");
1634
- lines.push(encodeBase64(await attachment.content));
2049
+ lines.push({ content: attachment.content });
1635
2050
  }
1636
2051
  lines.push("");
1637
2052
  lines.push(`--${boundary}--`);
@@ -1646,7 +2061,18 @@ async function buildRawMessage(message) {
1646
2061
  lines.push("");
1647
2062
  lines.push(encodeQuotedPrintable(message.content.text));
1648
2063
  }
1649
- return lines.join("\r\n");
2064
+ const parts = [];
2065
+ let text = "";
2066
+ for (const line of lines) {
2067
+ if (typeof line === "string") text += line;
2068
+ else {
2069
+ parts.push(text, line);
2070
+ text = "";
2071
+ }
2072
+ text += "\r\n";
2073
+ }
2074
+ parts.push(text);
2075
+ return parts;
1650
2076
  }
1651
2077
  function generateBoundary() {
1652
2078
  return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -1761,10 +2187,6 @@ function encodeQuotedPrintable(text) {
1761
2187
  }
1762
2188
  return result;
1763
2189
  }
1764
- function encodeBase64(data) {
1765
- const base64 = Buffer.from(data).toString("base64");
1766
- return base64.replace(/(.{76})/g, "$1\r\n").trim();
1767
- }
1768
2190
 
1769
2191
  //#endregion
1770
2192
  //#region src/smtp-transport.ts
@@ -1826,6 +2248,7 @@ var SmtpTransport = class {
1826
2248
  * and options.
1827
2249
  */
1828
2250
  constructor(config) {
2251
+ validateDkimBodyMode(config.dkim);
1829
2252
  this.config = config;
1830
2253
  this.poolSize = config.poolSize ?? 5;
1831
2254
  const auth = config.auth;
@@ -1867,7 +2290,7 @@ var SmtpTransport = class {
1867
2290
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1868
2291
  connection = await this.getConnection(options?.signal);
1869
2292
  options?.signal?.throwIfAborted();
1870
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2293
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1871
2294
  options?.signal?.throwIfAborted();
1872
2295
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1873
2296
  await this.returnConnection(connection);
@@ -1878,7 +2301,7 @@ var SmtpTransport = class {
1878
2301
  rejectedRecipients: result.rejectedRecipients
1879
2302
  };
1880
2303
  } catch (error) {
1881
- if (connection != null) if (isReusableLocalFailure(error)) await this.returnConnection(connection);
2304
+ if (connection != null) if (connection.usable && isReusableLocalFailure(error)) await this.returnConnection(connection);
1882
2305
  else await this.discardConnection(connection);
1883
2306
  options?.signal?.throwIfAborted();
1884
2307
  return createSmtpFailure(error instanceof Error ? error.message : String(error), error);
@@ -1942,7 +2365,7 @@ var SmtpTransport = class {
1942
2365
  try {
1943
2366
  const envelope = resolveEnvelopeOption(message, options?.envelope, index++);
1944
2367
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1945
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2368
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1946
2369
  options?.signal?.throwIfAborted();
1947
2370
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1948
2371
  yield {
@@ -1953,7 +2376,7 @@ var SmtpTransport = class {
1953
2376
  };
1954
2377
  } catch (error) {
1955
2378
  options?.signal?.throwIfAborted();
1956
- if (!isReusableLocalFailure(error)) connectionValid = false;
2379
+ if (!connection.usable || !isReusableLocalFailure(error)) connectionValid = false;
1957
2380
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1958
2381
  }
1959
2382
  }
@@ -1968,7 +2391,7 @@ var SmtpTransport = class {
1968
2391
  try {
1969
2392
  const envelope = resolveEnvelopeOption(message, options?.envelope, index++);
1970
2393
  const dsn = resolveSmtpDsn(envelope, options?.dsn);
1971
- const smtpMessage = await convertMessage(message, this.config.dkim, dsn, envelope);
2394
+ const smtpMessage = prepareMessage(message, this.config.dkim, dsn, envelope);
1972
2395
  options?.signal?.throwIfAborted();
1973
2396
  const result = await connection.sendMessage(smtpMessage, options?.signal);
1974
2397
  yield {
@@ -1979,7 +2402,7 @@ var SmtpTransport = class {
1979
2402
  };
1980
2403
  } catch (error) {
1981
2404
  options?.signal?.throwIfAborted();
1982
- if (!isReusableLocalFailure(error)) connectionValid = false;
2405
+ if (!connection.usable || !isReusableLocalFailure(error)) connectionValid = false;
1983
2406
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1984
2407
  }
1985
2408
  }
@@ -1993,7 +2416,11 @@ var SmtpTransport = class {
1993
2416
  }
1994
2417
  async getConnection(signal) {
1995
2418
  signal?.throwIfAborted();
1996
- if (this.connectionPool.length > 0) return this.connectionPool.pop();
2419
+ while (this.connectionPool.length > 0) {
2420
+ const connection$1 = this.connectionPool.pop();
2421
+ if (connection$1.usable) return connection$1;
2422
+ await this.discardConnection(connection$1);
2423
+ }
1997
2424
  const connection = new SmtpConnection(this.config, this.tokenManager);
1998
2425
  try {
1999
2426
  await this.connectAndSetup(connection, signal);
@@ -2021,7 +2448,7 @@ var SmtpTransport = class {
2021
2448
  await connection.authenticate(signal);
2022
2449
  }
2023
2450
  async returnConnection(connection) {
2024
- if (!connection.config.pool) {
2451
+ if (!connection.usable || !connection.config.pool) {
2025
2452
  await connection.quit();
2026
2453
  return;
2027
2454
  }
@@ -2096,6 +2523,13 @@ function createSmtpFailure(message, error) {
2096
2523
  retryable: false,
2097
2524
  attempts: 1
2098
2525
  });
2526
+ if (error instanceof SmtpAttachmentReplayError) return createFailedReceipt(message, {
2527
+ provider: "smtp",
2528
+ code: "smtp.attachment-replay-mismatch",
2529
+ category: "validation",
2530
+ retryable: false,
2531
+ attempts: 1
2532
+ });
2099
2533
  if (error instanceof SmtpMessageSizeError) return createFailedReceipt(message, {
2100
2534
  provider: "smtp",
2101
2535
  code: "smtp.message-size-exceeded",
@@ -2138,7 +2572,7 @@ function createSmtpFailure(message, error) {
2138
2572
  });
2139
2573
  }
2140
2574
  function isReusableLocalFailure(error) {
2141
- return error instanceof SmtpMessageSizeError || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpEnvelopeValidationError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2575
+ return error instanceof SmtpMessageSizeError && error.phase === "preflight" || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpEnvelopeValidationError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2142
2576
  }
2143
2577
  function resolveEnvelopeOption(message, option, index) {
2144
2578
  let override;
@@ -2190,4 +2624,4 @@ function isSmtpResponseProviderDetails(value) {
2190
2624
  }
2191
2625
 
2192
2626
  //#endregion
2193
- export { SmtpAuthError, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpEnvelopeValidationError, SmtpTransport, isSmtpResponseProviderDetails };
2627
+ export { SmtpAttachmentReplayError, SmtpAuthError, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpEnvelopeValidationError, SmtpTransport, isSmtpResponseProviderDetails };