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