@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.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.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
|
-
*
|
|
484
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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("
|
|
733
|
-
|
|
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
|
|
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/
|
|
1252
|
-
/**
|
|
1253
|
-
*
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
*
|
|
1531
|
-
*
|
|
1532
|
-
* @param
|
|
1533
|
-
* @param
|
|
1534
|
-
* @param
|
|
1535
|
-
* @
|
|
1536
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
-
|
|
1566
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 };
|