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