@mxraven/mail 0.1.0
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/LICENSE +201 -0
- package/README.md +215 -0
- package/dist/feedback/index.cjs +246 -0
- package/dist/feedback/index.cjs.map +1 -0
- package/dist/feedback/index.d.cts +149 -0
- package/dist/feedback/index.d.cts.map +1 -0
- package/dist/feedback/index.d.ts +149 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +243 -0
- package/dist/feedback/index.js.map +1 -0
- package/dist/index.cjs +2708 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +323 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +323 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2703 -0
- package/dist/index.js.map +1 -0
- package/dist/webhook/index.cjs +417 -0
- package/dist/webhook/index.cjs.map +1 -0
- package/dist/webhook/index.d.cts +411 -0
- package/dist/webhook/index.d.cts.map +1 -0
- package/dist/webhook/index.d.ts +411 -0
- package/dist/webhook/index.d.ts.map +1 -0
- package/dist/webhook/index.js +409 -0
- package/dist/webhook/index.js.map +1 -0
- package/package.json +101 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2708 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_buffer = require("node:buffer");
|
|
3
|
+
let node_net = require("node:net");
|
|
4
|
+
let node_tls = require("node:tls");
|
|
5
|
+
let node_crypto = require("node:crypto");
|
|
6
|
+
//#region src/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* An SMTP reply that rejected a submission.
|
|
9
|
+
*
|
|
10
|
+
* It is thrown when the server refuses a command, for example when the sender
|
|
11
|
+
* domain is not authorized or a recipient is rejected. Inspect {@link SMTPError.code}
|
|
12
|
+
* or {@link SMTPError.enhancedCode} to make a delivery decision; the `message`
|
|
13
|
+
* is intended for humans and is not stable.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var SMTPError = class extends Error {
|
|
18
|
+
/** The three-digit SMTP reply code. */
|
|
19
|
+
code;
|
|
20
|
+
/** The RFC 3463 enhanced status code, when the server supplied one. */
|
|
21
|
+
enhancedCode;
|
|
22
|
+
/** @param options - The reply code, optional enhanced code, and reply text. */
|
|
23
|
+
constructor(options) {
|
|
24
|
+
super(options.message);
|
|
25
|
+
this.name = "SMTPError";
|
|
26
|
+
this.code = options.code;
|
|
27
|
+
this.enhancedCode = options.enhancedCode;
|
|
28
|
+
}
|
|
29
|
+
/** Reports whether the failure is permanent (5xx). Retrying is unlikely to succeed. */
|
|
30
|
+
get permanent() {
|
|
31
|
+
return this.code >= 500 && this.code < 600;
|
|
32
|
+
}
|
|
33
|
+
/** Reports whether the failure is transient (4xx). The message may be retried later. */
|
|
34
|
+
get transient() {
|
|
35
|
+
return this.code >= 400 && this.code < 500;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* A submission the server rejected after per-recipient results were collected.
|
|
40
|
+
*
|
|
41
|
+
* It is thrown when every envelope recipient was rejected. The per-recipient
|
|
42
|
+
* detail remains available in {@link SMTPTransactionError.result}.
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
var SMTPTransactionError = class extends Error {
|
|
47
|
+
/** The per-recipient results collected before the failure. */
|
|
48
|
+
result;
|
|
49
|
+
/**
|
|
50
|
+
* @param message - A human-readable description.
|
|
51
|
+
* @param result - The partial submission result.
|
|
52
|
+
*/
|
|
53
|
+
constructor(message, result) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "SMTPTransactionError";
|
|
56
|
+
this.result = result;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/internal/address.ts
|
|
61
|
+
/**
|
|
62
|
+
* RFC 5322 mailbox parsing and formatting.
|
|
63
|
+
*
|
|
64
|
+
* This module is internal to the SDK. It implements the RFC 5322 mailbox
|
|
65
|
+
* grammar, including comments and folding whitespace (`CFWS`), quoted strings,
|
|
66
|
+
* quoted local parts, domain literals, and internationalized addresses.
|
|
67
|
+
*
|
|
68
|
+
* Group syntax (`display-name: mailbox-list;`) is not supported.
|
|
69
|
+
*
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
/** Reports whether a string contains a non-ASCII code unit. */
|
|
73
|
+
function containsNonAscii(value) {
|
|
74
|
+
for (let index = 0; index < value.length; index += 1) if (value.charCodeAt(index) >= 128) return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/** Returns the bare `local@domain` form of an address. */
|
|
78
|
+
function mailboxToString(address) {
|
|
79
|
+
if (address.localPart === "" && address.domain === "") return "";
|
|
80
|
+
return `${needsQuoting(address.localPart) ? `"${address.localPart.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"` : address.localPart}@${address.domain}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parses a single mailbox address.
|
|
84
|
+
*
|
|
85
|
+
* Accepts the RFC 5322 `mailbox` productions — `addr-spec`,
|
|
86
|
+
* `[display-name] angle-addr` — with comments and folding whitespace anywhere
|
|
87
|
+
* the grammar permits them. Comments are discarded except for a trailing
|
|
88
|
+
* comment after a bare `addr-spec`, which is used as the display name.
|
|
89
|
+
*
|
|
90
|
+
* @param input - The address text.
|
|
91
|
+
* @returns The parsed mailbox.
|
|
92
|
+
* @throws `Error` When the input contains a line break, is empty, or is not a
|
|
93
|
+
* valid mailbox.
|
|
94
|
+
*
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
function parseAddress(input) {
|
|
98
|
+
return new AddressParser(input).parse();
|
|
99
|
+
}
|
|
100
|
+
/** Formats one address for use in a header field. */
|
|
101
|
+
function formatAddress(address) {
|
|
102
|
+
const email = mailboxToString(address);
|
|
103
|
+
const name = address.displayName;
|
|
104
|
+
if (name === void 0 || name === "") return email;
|
|
105
|
+
if (containsNonAscii(name)) return `${encodeRfc2047(name)} <${email}>`;
|
|
106
|
+
if (/[!"(),.:;<>@[\\\]]/.test(name)) return `"${name.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}" <${email}>`;
|
|
107
|
+
return `${name} <${email}>`;
|
|
108
|
+
}
|
|
109
|
+
/** Formats several addresses as a comma-separated header value. */
|
|
110
|
+
function formatAddressList(addresses) {
|
|
111
|
+
return addresses.map(formatAddress).join(", ");
|
|
112
|
+
}
|
|
113
|
+
/** Encodes a string using RFC 2047 Base64 (`=?UTF-8?B?...?=`). */
|
|
114
|
+
function encodeRfc2047(value) {
|
|
115
|
+
return `=?UTF-8?B?${node_buffer.Buffer.from(value, "utf8").toString("base64")}?=`;
|
|
116
|
+
}
|
|
117
|
+
/** Reports whether a local part must be quoted when serialized. */
|
|
118
|
+
function needsQuoting(localPart) {
|
|
119
|
+
if (containsNonAscii(localPart)) return false;
|
|
120
|
+
if (localPart.startsWith(".") || localPart.endsWith(".") || localPart.includes("..")) return true;
|
|
121
|
+
return /[^A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]/.test(localPart);
|
|
122
|
+
}
|
|
123
|
+
/** Reports whether a code point is an RFC 5322 `atext` character. */
|
|
124
|
+
function isAtext(char) {
|
|
125
|
+
const code = char.charCodeAt(0);
|
|
126
|
+
if (code >= 65 && code <= 90 || code >= 97 && code <= 122 || code >= 48 && code <= 57) return true;
|
|
127
|
+
switch (char) {
|
|
128
|
+
case "!":
|
|
129
|
+
case "#":
|
|
130
|
+
case "$":
|
|
131
|
+
case "%":
|
|
132
|
+
case "&":
|
|
133
|
+
case "'":
|
|
134
|
+
case "*":
|
|
135
|
+
case "+":
|
|
136
|
+
case "-":
|
|
137
|
+
case "/":
|
|
138
|
+
case "=":
|
|
139
|
+
case "?":
|
|
140
|
+
case "^":
|
|
141
|
+
case "_":
|
|
142
|
+
case "`":
|
|
143
|
+
case "{":
|
|
144
|
+
case "|":
|
|
145
|
+
case "}":
|
|
146
|
+
case "~": return true;
|
|
147
|
+
default: return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Joins phrase words, attaching stray dots without a preceding space. */
|
|
151
|
+
function joinPhrase(words) {
|
|
152
|
+
let result = "";
|
|
153
|
+
for (const word of words) if (word === ".") result += ".";
|
|
154
|
+
else if (result === "") result = word;
|
|
155
|
+
else result += ` ${word}`;
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
/** A recursive-descent parser for one RFC 5322 mailbox. */
|
|
159
|
+
var AddressParser = class {
|
|
160
|
+
source;
|
|
161
|
+
chars;
|
|
162
|
+
pos = 0;
|
|
163
|
+
constructor(source) {
|
|
164
|
+
this.source = source;
|
|
165
|
+
this.chars = [...source];
|
|
166
|
+
}
|
|
167
|
+
parse() {
|
|
168
|
+
const start = this.pos;
|
|
169
|
+
this.skipCfws();
|
|
170
|
+
if (this.peek() === "<") {
|
|
171
|
+
const angle = this.parseAngleAddr();
|
|
172
|
+
this.skipCfws();
|
|
173
|
+
this.requireEnd();
|
|
174
|
+
return angle;
|
|
175
|
+
}
|
|
176
|
+
this.pos = start;
|
|
177
|
+
this.skipCfws();
|
|
178
|
+
const spec = this.tryAddrSpec();
|
|
179
|
+
if (spec !== void 0) {
|
|
180
|
+
const name = this.consumeTrailingComment();
|
|
181
|
+
if (this.atEnd()) return name === void 0 ? spec : {
|
|
182
|
+
localPart: spec.localPart,
|
|
183
|
+
domain: spec.domain,
|
|
184
|
+
displayName: name
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
this.pos = start;
|
|
188
|
+
this.skipCfws();
|
|
189
|
+
const displayName = this.parsePhrase();
|
|
190
|
+
this.skipCfws();
|
|
191
|
+
if (this.peek() !== "<") this.fail(`invalid address ${JSON.stringify(this.source)}`);
|
|
192
|
+
const angle = this.parseAngleAddr();
|
|
193
|
+
this.skipCfws();
|
|
194
|
+
this.requireEnd();
|
|
195
|
+
return displayName === "" ? angle : {
|
|
196
|
+
localPart: angle.localPart,
|
|
197
|
+
domain: angle.domain,
|
|
198
|
+
displayName
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
tryAddrSpec() {
|
|
202
|
+
const save = this.pos;
|
|
203
|
+
try {
|
|
204
|
+
return this.parseAddrSpec();
|
|
205
|
+
} catch {
|
|
206
|
+
this.pos = save;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
parseAddrSpec() {
|
|
211
|
+
const localPart = this.parseLocalPart();
|
|
212
|
+
this.skipCfws();
|
|
213
|
+
this.expect("@");
|
|
214
|
+
this.skipCfws();
|
|
215
|
+
return {
|
|
216
|
+
localPart,
|
|
217
|
+
domain: this.parseDomain()
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
parseLocalPart() {
|
|
221
|
+
if (this.peek() === "\"") return this.parseQuotedString();
|
|
222
|
+
return this.parseDotAtomText(true);
|
|
223
|
+
}
|
|
224
|
+
parseDomain() {
|
|
225
|
+
if (this.peek() === "[") return this.parseDomainLiteral();
|
|
226
|
+
return this.parseDotAtomText(true);
|
|
227
|
+
}
|
|
228
|
+
parseAngleAddr() {
|
|
229
|
+
this.skipCfws();
|
|
230
|
+
this.expect("<");
|
|
231
|
+
this.skipCfws();
|
|
232
|
+
const spec = this.parseAddrSpec();
|
|
233
|
+
this.skipCfws();
|
|
234
|
+
this.expect(">");
|
|
235
|
+
return spec;
|
|
236
|
+
}
|
|
237
|
+
parsePhrase() {
|
|
238
|
+
const words = [];
|
|
239
|
+
for (;;) {
|
|
240
|
+
this.skipCfws();
|
|
241
|
+
const char = this.peek();
|
|
242
|
+
if (char === void 0 || char === "<" || char === ":" || char === "@" || char === ",") break;
|
|
243
|
+
if (char === ".") {
|
|
244
|
+
words.push(".");
|
|
245
|
+
this.pos += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (char === "\"") {
|
|
249
|
+
words.push(this.parseQuotedString());
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
words.push(this.parseAtom(true));
|
|
253
|
+
}
|
|
254
|
+
return joinPhrase(words);
|
|
255
|
+
}
|
|
256
|
+
parseAtom(allowUtf8) {
|
|
257
|
+
const start = this.pos;
|
|
258
|
+
for (;;) {
|
|
259
|
+
const char = this.peek();
|
|
260
|
+
if (char === void 0) break;
|
|
261
|
+
if (isAtext(char) || allowUtf8 && (char.codePointAt(0) ?? 0) >= 128) {
|
|
262
|
+
this.pos += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (this.pos === start) this.fail(`expected an atom in ${JSON.stringify(this.source)}`);
|
|
268
|
+
return this.chars.slice(start, this.pos).join("");
|
|
269
|
+
}
|
|
270
|
+
parseDotAtomText(allowUtf8) {
|
|
271
|
+
let value = this.parseAtom(allowUtf8);
|
|
272
|
+
while (this.peek() === ".") {
|
|
273
|
+
this.pos += 1;
|
|
274
|
+
value += `.${this.parseAtom(allowUtf8)}`;
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
parseQuotedString() {
|
|
279
|
+
this.expect("\"");
|
|
280
|
+
let value = "";
|
|
281
|
+
for (;;) {
|
|
282
|
+
const char = this.peek();
|
|
283
|
+
if (char === void 0) this.fail(`unterminated quoted string in ${JSON.stringify(this.source)}`);
|
|
284
|
+
if (char === "\"") {
|
|
285
|
+
this.pos += 1;
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
if (char === "\\") {
|
|
289
|
+
this.pos += 1;
|
|
290
|
+
const escaped = this.peek();
|
|
291
|
+
if (escaped === void 0 || escaped === "\r" || escaped === "\n" || escaped === "\0") this.fail(`invalid quoted pair in ${JSON.stringify(this.source)}`);
|
|
292
|
+
value += escaped;
|
|
293
|
+
this.pos += 1;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (char === "\r" || char === "\n") {
|
|
297
|
+
value += this.consumeFoldedWhitespace();
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if ((char.codePointAt(0) ?? 0) < 32 && char !== " ") this.fail(`invalid character in quoted string ${JSON.stringify(this.source)}`);
|
|
301
|
+
value += char;
|
|
302
|
+
this.pos += 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
parseDomainLiteral() {
|
|
306
|
+
this.expect("[");
|
|
307
|
+
let value = "";
|
|
308
|
+
for (;;) {
|
|
309
|
+
const char = this.peek();
|
|
310
|
+
if (char === void 0) this.fail(`unterminated domain literal in ${JSON.stringify(this.source)}`);
|
|
311
|
+
if (char === "]") {
|
|
312
|
+
this.pos += 1;
|
|
313
|
+
return `[${value}]`;
|
|
314
|
+
}
|
|
315
|
+
if (char === "\\") {
|
|
316
|
+
this.pos += 1;
|
|
317
|
+
const escaped = this.peek();
|
|
318
|
+
if (escaped === void 0 || escaped === "\r" || escaped === "\n") this.fail(`invalid quoted pair in domain literal`);
|
|
319
|
+
value += escaped;
|
|
320
|
+
this.pos += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const code = char.codePointAt(0) ?? 0;
|
|
324
|
+
if (code < 33 || code > 126 || char === "[") this.fail(`invalid character in domain literal ${JSON.stringify(this.source)}`);
|
|
325
|
+
value += char;
|
|
326
|
+
this.pos += 1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
consumeTrailingComment() {
|
|
330
|
+
let name;
|
|
331
|
+
for (;;) {
|
|
332
|
+
this.skipFws();
|
|
333
|
+
if (this.peek() !== "(") break;
|
|
334
|
+
const text = this.consumeComment();
|
|
335
|
+
if (name === void 0 && text !== "") name = text;
|
|
336
|
+
}
|
|
337
|
+
return name;
|
|
338
|
+
}
|
|
339
|
+
consumeComment() {
|
|
340
|
+
this.expect("(");
|
|
341
|
+
let depth = 1;
|
|
342
|
+
let text = "";
|
|
343
|
+
for (;;) {
|
|
344
|
+
const char = this.peek();
|
|
345
|
+
if (char === void 0) this.fail(`unterminated comment in ${JSON.stringify(this.source)}`);
|
|
346
|
+
if (char === "(") {
|
|
347
|
+
depth += 1;
|
|
348
|
+
this.pos += 1;
|
|
349
|
+
text = appendWordSeparator(text);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (char === ")") {
|
|
353
|
+
depth -= 1;
|
|
354
|
+
this.pos += 1;
|
|
355
|
+
if (depth === 0) return text.trim();
|
|
356
|
+
text = appendWordSeparator(text);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (char === "\\") {
|
|
360
|
+
this.pos += 1;
|
|
361
|
+
const escaped = this.peek();
|
|
362
|
+
if (escaped === void 0 || escaped === "\r" || escaped === "\n" || escaped === "\0") this.fail(`invalid quoted pair in comment`);
|
|
363
|
+
text += escaped;
|
|
364
|
+
this.pos += 1;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (char === "\r" || char === "\n") {
|
|
368
|
+
text += this.consumeFoldedWhitespace();
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if ((char.codePointAt(0) ?? 0) < 32 && char !== " ") this.fail(`invalid character in comment ${JSON.stringify(this.source)}`);
|
|
372
|
+
text += char;
|
|
373
|
+
this.pos += 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
skipCfws() {
|
|
377
|
+
for (;;) {
|
|
378
|
+
this.skipFws();
|
|
379
|
+
if (this.peek() === "(") {
|
|
380
|
+
this.consumeComment();
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
skipFws() {
|
|
387
|
+
for (;;) {
|
|
388
|
+
const char = this.peek();
|
|
389
|
+
if (char === " " || char === " ") {
|
|
390
|
+
this.pos += 1;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (char === "\r" || char === "\n") {
|
|
394
|
+
this.consumeFoldedWhitespace();
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
consumeFoldedWhitespace() {
|
|
401
|
+
if (this.peek() === "\r") {
|
|
402
|
+
if (this.lookahead(1) !== "\n") this.fail("bare carriage return");
|
|
403
|
+
if (this.lookahead(2) !== " " && this.lookahead(2) !== " ") this.fail("folding without trailing whitespace");
|
|
404
|
+
this.pos += 3;
|
|
405
|
+
} else this.fail("bare line feed");
|
|
406
|
+
while (this.peek() === " " || this.peek() === " ") this.pos += 1;
|
|
407
|
+
return " ";
|
|
408
|
+
}
|
|
409
|
+
expect(char) {
|
|
410
|
+
if (this.peek() !== char) this.fail(`expected ${JSON.stringify(char)} in ${JSON.stringify(this.source)}`);
|
|
411
|
+
this.pos += 1;
|
|
412
|
+
}
|
|
413
|
+
requireEnd() {
|
|
414
|
+
if (!this.atEnd()) this.fail(`unexpected text after address in ${JSON.stringify(this.source)}`);
|
|
415
|
+
}
|
|
416
|
+
atEnd() {
|
|
417
|
+
return this.pos >= this.chars.length;
|
|
418
|
+
}
|
|
419
|
+
peek() {
|
|
420
|
+
return this.chars[this.pos];
|
|
421
|
+
}
|
|
422
|
+
lookahead(offset) {
|
|
423
|
+
return this.chars[this.pos + offset];
|
|
424
|
+
}
|
|
425
|
+
fail(message) {
|
|
426
|
+
throw new Error(`mail: ${message}`);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
/** Ensures a comment word separator between accumulated fragments. */
|
|
430
|
+
function appendWordSeparator(text) {
|
|
431
|
+
return text === "" || text.endsWith(" ") ? text : `${text} `;
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/internal/smtp/errors.ts
|
|
435
|
+
/** The category of an {@link SmtpSessionError}. */
|
|
436
|
+
const smtpSessionErrorKind = {
|
|
437
|
+
/** The session has already been closed. */
|
|
438
|
+
clientClosed: "client-closed",
|
|
439
|
+
/** No connection has been established. */
|
|
440
|
+
noConnection: "no-connection",
|
|
441
|
+
/** The connection was closed by the peer. */
|
|
442
|
+
connectionClosed: "connection-closed",
|
|
443
|
+
/** The server did not advertise a required extension. */
|
|
444
|
+
extensionNotSupported: "extension-not-supported",
|
|
445
|
+
/** Authentication failed. */
|
|
446
|
+
authFailed: "auth-failed",
|
|
447
|
+
/** No credentials were configured for authentication. */
|
|
448
|
+
noCredentials: "no-credentials",
|
|
449
|
+
/** The requested authentication mechanism is not supported. */
|
|
450
|
+
unsupportedMechanism: "unsupported-mechanism",
|
|
451
|
+
/** The envelope has no recipients. */
|
|
452
|
+
noRecipients: "no-recipients",
|
|
453
|
+
/** The transaction failed because every recipient was rejected. */
|
|
454
|
+
transactionFailed: "transaction-failed",
|
|
455
|
+
/** The server refused the DATA command. */
|
|
456
|
+
dataFailed: "data-failed",
|
|
457
|
+
/** The server does not support the requested DELIVERBY extension. */
|
|
458
|
+
deliveryByNotSupported: "delivery-by-not-supported",
|
|
459
|
+
/** The connection is already protected by TLS. */
|
|
460
|
+
tlsAlreadyActive: "tls-already-active",
|
|
461
|
+
/** The server does not support STARTTLS. */
|
|
462
|
+
tlsNotSupported: "tls-not-supported",
|
|
463
|
+
/** The server sent a malformed response. */
|
|
464
|
+
unexpectedResponse: "unexpected-response"
|
|
465
|
+
};
|
|
466
|
+
/** Reports a connection or session state failure. */
|
|
467
|
+
var SmtpSessionError = class extends Error {
|
|
468
|
+
/** The failure category. */
|
|
469
|
+
kind;
|
|
470
|
+
/**
|
|
471
|
+
* @param kind - The failure category.
|
|
472
|
+
* @param message - A human-readable description.
|
|
473
|
+
* @param options - An optional cause.
|
|
474
|
+
*/
|
|
475
|
+
constructor(kind, message, options) {
|
|
476
|
+
super(message, options);
|
|
477
|
+
this.name = "SmtpSessionError";
|
|
478
|
+
this.kind = kind;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
/** Reports that an SMTP operation exceeded its deadline. */
|
|
482
|
+
var SmtpTimeoutError = class extends Error {
|
|
483
|
+
/**
|
|
484
|
+
* @param operation - The operation that timed out.
|
|
485
|
+
*/
|
|
486
|
+
constructor(operation) {
|
|
487
|
+
super(`smtp: ${operation} timed out`);
|
|
488
|
+
this.name = "SmtpTimeoutError";
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
/** Reports an aborted SMTP operation. */
|
|
492
|
+
var SmtpAbortError = class extends Error {
|
|
493
|
+
/**
|
|
494
|
+
* @param options - The abort cause.
|
|
495
|
+
*/
|
|
496
|
+
constructor(options) {
|
|
497
|
+
super("smtp: operation aborted", options);
|
|
498
|
+
this.name = "SmtpAbortError";
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
/** Reports a transaction failure that still has per-recipient results. */
|
|
502
|
+
var SmtpTransactionError = class extends SmtpSessionError {
|
|
503
|
+
/** The per-recipient results collected before the failure. */
|
|
504
|
+
result;
|
|
505
|
+
/**
|
|
506
|
+
* @param message - A human-readable description.
|
|
507
|
+
* @param result - The partial transaction result.
|
|
508
|
+
*/
|
|
509
|
+
constructor(message, result) {
|
|
510
|
+
super(smtpSessionErrorKind.transactionFailed, message);
|
|
511
|
+
this.name = "SmtpTransactionError";
|
|
512
|
+
this.result = result;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
//#endregion
|
|
516
|
+
//#region src/internal/smtp/extensions.ts
|
|
517
|
+
/**
|
|
518
|
+
* SMTP extension parsing and capability inspection.
|
|
519
|
+
*
|
|
520
|
+
* @internal
|
|
521
|
+
*/
|
|
522
|
+
/** Extension names advertised in an EHLO reply. */
|
|
523
|
+
const smtpExtension = {
|
|
524
|
+
/** `SIZE`, with the maximum message size as its parameter. */
|
|
525
|
+
size: "SIZE",
|
|
526
|
+
/** `PIPELINING`. */
|
|
527
|
+
pipelining: "PIPELINING",
|
|
528
|
+
/** `8BITMIME`. */
|
|
529
|
+
eightBitMime: "8BITMIME",
|
|
530
|
+
/** `AUTH`, with the supported mechanisms as its parameter. */
|
|
531
|
+
auth: "AUTH",
|
|
532
|
+
/** `STARTTLS`. */
|
|
533
|
+
startTls: "STARTTLS",
|
|
534
|
+
/** `ENHANCEDSTATUSCODES`. */
|
|
535
|
+
enhancedStatusCodes: "ENHANCEDSTATUSCODES",
|
|
536
|
+
/** `SMTPUTF8`. */
|
|
537
|
+
smtpUtf8: "SMTPUTF8",
|
|
538
|
+
/** `DSN`. */
|
|
539
|
+
dsn: "DSN",
|
|
540
|
+
/** `CHUNKING`. */
|
|
541
|
+
chunking: "CHUNKING",
|
|
542
|
+
/** `BINARYMIME`. */
|
|
543
|
+
binaryMime: "BINARYMIME",
|
|
544
|
+
/** `DELIVERBY`, with the minimum interval in seconds as its parameter. */
|
|
545
|
+
deliverBy: "DELIVERBY",
|
|
546
|
+
/** `REQUIRETLS` (RFC 8689). */
|
|
547
|
+
requireTls: "REQUIRETLS"
|
|
548
|
+
};
|
|
549
|
+
/**
|
|
550
|
+
* Parses EHLO reply lines into an extension map.
|
|
551
|
+
*
|
|
552
|
+
* The first line carries the server greeting and is ignored; each subsequent
|
|
553
|
+
* line is `NAME` or `NAME params`.
|
|
554
|
+
*
|
|
555
|
+
* @param lines - The EHLO reply text lines.
|
|
556
|
+
* @returns The advertised extensions keyed by uppercase name.
|
|
557
|
+
*/
|
|
558
|
+
function parseExtensions(lines) {
|
|
559
|
+
const extensions = /* @__PURE__ */ new Map();
|
|
560
|
+
for (const line of lines.slice(1)) {
|
|
561
|
+
const separator = line.indexOf(" ");
|
|
562
|
+
if (separator === -1) extensions.set(line.toUpperCase(), "");
|
|
563
|
+
else extensions.set(line.slice(0, separator).toUpperCase(), line.slice(separator + 1));
|
|
564
|
+
}
|
|
565
|
+
return extensions;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Builds a capability view over an extension map.
|
|
569
|
+
*
|
|
570
|
+
* @param extensions - The extensions keyed by uppercase name.
|
|
571
|
+
* @param isEsmtp - Whether EHLO succeeded.
|
|
572
|
+
* @param hostname - The server hostname from the EHLO reply.
|
|
573
|
+
* @returns A read-only capability view.
|
|
574
|
+
*/
|
|
575
|
+
function capabilitiesFrom(extensions, isEsmtp, hostname) {
|
|
576
|
+
const getExtensionParam = (name) => extensions.get(name.toUpperCase()) ?? "";
|
|
577
|
+
const hasExtension = (name) => extensions.has(name.toUpperCase());
|
|
578
|
+
const auth = getExtensionParam(smtpExtension.auth).split(/\s+/).filter((value) => value !== "");
|
|
579
|
+
return {
|
|
580
|
+
isEsmtp,
|
|
581
|
+
hostname,
|
|
582
|
+
tls: hasExtension(smtpExtension.startTls),
|
|
583
|
+
pipelining: hasExtension(smtpExtension.pipelining),
|
|
584
|
+
eightBitMime: hasExtension(smtpExtension.eightBitMime),
|
|
585
|
+
smtpUtf8: hasExtension(smtpExtension.smtpUtf8),
|
|
586
|
+
dsn: hasExtension(smtpExtension.dsn),
|
|
587
|
+
chunking: hasExtension(smtpExtension.chunking),
|
|
588
|
+
binaryMime: hasExtension(smtpExtension.binaryMime),
|
|
589
|
+
enhancedStatusCodes: hasExtension(smtpExtension.enhancedStatusCodes),
|
|
590
|
+
deliveryBy: hasExtension(smtpExtension.deliverBy),
|
|
591
|
+
deliveryByMinSeconds: parsePositiveInt(getExtensionParam(smtpExtension.deliverBy)),
|
|
592
|
+
maxSize: parsePositiveInt(getExtensionParam(smtpExtension.size)),
|
|
593
|
+
auth,
|
|
594
|
+
hasExtension,
|
|
595
|
+
getExtensionParam,
|
|
596
|
+
supportsAuth: (mechanism) => auth.some((advertised) => advertised.toLowerCase() === mechanism.toLowerCase())
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/** Parses a non-negative decimal integer, returning 0 when invalid. */
|
|
600
|
+
function parsePositiveInt(value) {
|
|
601
|
+
if (!/^\d+$/.test(value.trim())) return 0;
|
|
602
|
+
const parsed = Number.parseInt(value, 10);
|
|
603
|
+
return Number.isSafeInteger(parsed) ? parsed : 0;
|
|
604
|
+
}
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/internal/smtp/auth.ts
|
|
607
|
+
/**
|
|
608
|
+
* SASL mechanism selection and encoding.
|
|
609
|
+
*
|
|
610
|
+
* Only `PLAIN` and `LOGIN` are supported, matching the submission service.
|
|
611
|
+
*
|
|
612
|
+
* @internal
|
|
613
|
+
*/
|
|
614
|
+
/**
|
|
615
|
+
* Selects an authentication mechanism the server also advertises.
|
|
616
|
+
*
|
|
617
|
+
* When the caller provides preferred mechanisms they are tried in order;
|
|
618
|
+
* otherwise `PLAIN` is preferred over `LOGIN`, matching the server's own
|
|
619
|
+
* capability ordering.
|
|
620
|
+
*
|
|
621
|
+
* @param preferred - Client-preferred mechanisms, in order.
|
|
622
|
+
* @param serverMechanisms - Mechanisms advertised by the server.
|
|
623
|
+
* @returns The selected mechanism in uppercase, or an empty string.
|
|
624
|
+
*/
|
|
625
|
+
function selectAuthMechanism(preferred, serverMechanisms) {
|
|
626
|
+
if (preferred.length > 0) {
|
|
627
|
+
for (const candidate of preferred) if (serverMechanisms.some((server) => server.toLowerCase() === candidate.toLowerCase())) return candidate.toUpperCase();
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
630
|
+
for (const candidate of ["PLAIN", "LOGIN"]) if (serverMechanisms.some((server) => server.toLowerCase() === candidate.toLowerCase())) return candidate;
|
|
631
|
+
return "";
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Encodes `PLAIN` credentials as `\0username\0password`.
|
|
635
|
+
*
|
|
636
|
+
* @param username - The submission key username.
|
|
637
|
+
* @param password - The submission key secret.
|
|
638
|
+
* @returns The Base64-encoded SASL payload.
|
|
639
|
+
*/
|
|
640
|
+
function encodePlainAuth(username, password) {
|
|
641
|
+
return node_buffer.Buffer.from(`\u0000${username}\u0000${password}`, "utf8").toString("base64");
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Encodes one `LOGIN` step.
|
|
645
|
+
*
|
|
646
|
+
* @param value - The username or password.
|
|
647
|
+
* @returns The Base64-encoded value.
|
|
648
|
+
*/
|
|
649
|
+
function encodeLoginAuth(value) {
|
|
650
|
+
return node_buffer.Buffer.from(value, "utf8").toString("base64");
|
|
651
|
+
}
|
|
652
|
+
//#endregion
|
|
653
|
+
//#region src/internal/smtp/connection.ts
|
|
654
|
+
/**
|
|
655
|
+
* A CRLF line-oriented socket connection used by the SMTP session.
|
|
656
|
+
*
|
|
657
|
+
* The connection owns a single TCP or TLS socket, buffers incoming bytes, and
|
|
658
|
+
* resolves one line at a time. Reads and writes are sequential, as SMTP
|
|
659
|
+
* requires; a connection is never used concurrently.
|
|
660
|
+
*
|
|
661
|
+
* @internal
|
|
662
|
+
*/
|
|
663
|
+
/** A line-oriented connection over TCP or TLS. */
|
|
664
|
+
var LineConnection = class LineConnection {
|
|
665
|
+
socket;
|
|
666
|
+
buffer = node_buffer.Buffer.alloc(0);
|
|
667
|
+
pending;
|
|
668
|
+
failure;
|
|
669
|
+
closed = false;
|
|
670
|
+
constructor(socket) {
|
|
671
|
+
this.socket = socket;
|
|
672
|
+
this.attach();
|
|
673
|
+
}
|
|
674
|
+
/** Opens a plain TCP connection. */
|
|
675
|
+
static async openTcp(options, signal) {
|
|
676
|
+
const socket = (0, node_net.connect)({
|
|
677
|
+
host: options.host,
|
|
678
|
+
port: options.port,
|
|
679
|
+
localAddress: options.localAddress
|
|
680
|
+
});
|
|
681
|
+
try {
|
|
682
|
+
await waitForReady(socket, "connect", options.connectTimeout, signal, "connect");
|
|
683
|
+
} catch (error) {
|
|
684
|
+
socket.destroy();
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
return new LineConnection(socket);
|
|
688
|
+
}
|
|
689
|
+
/** Opens a connection using implicit TLS. */
|
|
690
|
+
static async openTls(options, signal) {
|
|
691
|
+
const raw = (0, node_net.connect)({
|
|
692
|
+
host: options.host,
|
|
693
|
+
port: options.port,
|
|
694
|
+
localAddress: options.localAddress
|
|
695
|
+
});
|
|
696
|
+
await waitForReady(raw, "connect", options.connectTimeout, signal, "connect");
|
|
697
|
+
const socket = (0, node_tls.connect)({
|
|
698
|
+
...options.tls,
|
|
699
|
+
socket: raw
|
|
700
|
+
});
|
|
701
|
+
try {
|
|
702
|
+
await waitForReady(socket, "secureConnect", options.connectTimeout, signal, "start TLS");
|
|
703
|
+
} catch (error) {
|
|
704
|
+
raw.destroy();
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
return new LineConnection(socket);
|
|
708
|
+
}
|
|
709
|
+
/** Reports whether the connection is protected by TLS. */
|
|
710
|
+
get isTls() {
|
|
711
|
+
return this.socket instanceof node_tls.TLSSocket;
|
|
712
|
+
}
|
|
713
|
+
/** Reads one CRLF-terminated line, including its terminator. */
|
|
714
|
+
readLine(options) {
|
|
715
|
+
if (this.failure !== void 0) return Promise.reject(this.failure);
|
|
716
|
+
return new Promise((resolve, reject) => {
|
|
717
|
+
let settled = false;
|
|
718
|
+
let timer;
|
|
719
|
+
let onAbort;
|
|
720
|
+
const cleanup = () => {
|
|
721
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
722
|
+
if (onAbort !== void 0) options.signal?.removeEventListener("abort", onAbort);
|
|
723
|
+
};
|
|
724
|
+
const settle = (settleWith) => {
|
|
725
|
+
if (settled) return;
|
|
726
|
+
settled = true;
|
|
727
|
+
cleanup();
|
|
728
|
+
if (this.pending === pending) this.pending = void 0;
|
|
729
|
+
settleWith();
|
|
730
|
+
};
|
|
731
|
+
const pending = {
|
|
732
|
+
resolve: (line) => settle(() => resolve(line)),
|
|
733
|
+
reject: (error) => settle(() => reject(error))
|
|
734
|
+
};
|
|
735
|
+
if (options.signal?.aborted === true) {
|
|
736
|
+
settle(() => reject(abortError(options.signal)));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
timer = setTimeout(() => settle(() => reject(new SmtpTimeoutError(options.operation))), options.timeout);
|
|
740
|
+
onAbort = () => settle(() => reject(abortError(options.signal)));
|
|
741
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
742
|
+
this.pending = pending;
|
|
743
|
+
this.drain();
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
/** Writes bytes to the connection. */
|
|
747
|
+
write(data, options) {
|
|
748
|
+
if (this.failure !== void 0) return Promise.reject(this.failure);
|
|
749
|
+
return new Promise((resolve, reject) => {
|
|
750
|
+
let settled = false;
|
|
751
|
+
let timer;
|
|
752
|
+
let onAbort;
|
|
753
|
+
const cleanup = () => {
|
|
754
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
755
|
+
if (onAbort !== void 0) options.signal?.removeEventListener("abort", onAbort);
|
|
756
|
+
};
|
|
757
|
+
const settle = (settleWith) => {
|
|
758
|
+
if (settled) return;
|
|
759
|
+
settled = true;
|
|
760
|
+
cleanup();
|
|
761
|
+
settleWith();
|
|
762
|
+
};
|
|
763
|
+
if (options.signal?.aborted === true) {
|
|
764
|
+
settle(() => reject(abortError(options.signal)));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
timer = setTimeout(() => settle(() => reject(new SmtpTimeoutError(options.operation))), options.timeout);
|
|
768
|
+
onAbort = () => settle(() => reject(abortError(options.signal)));
|
|
769
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
770
|
+
this.socket.write(data, (error) => {
|
|
771
|
+
if (error !== void 0 && error !== null) settle(() => reject(error));
|
|
772
|
+
else settle(resolve);
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
/** Upgrades the connection to TLS after a successful STARTTLS reply. */
|
|
777
|
+
async upgradeToTls(options, timeout, signal) {
|
|
778
|
+
if (this.failure !== void 0) throw this.failure;
|
|
779
|
+
this.detach();
|
|
780
|
+
const secure = (0, node_tls.connect)({
|
|
781
|
+
...options,
|
|
782
|
+
socket: this.socket
|
|
783
|
+
});
|
|
784
|
+
this.socket = secure;
|
|
785
|
+
this.attach();
|
|
786
|
+
try {
|
|
787
|
+
await waitForReady(secure, "secureConnect", timeout, signal, "start TLS");
|
|
788
|
+
} catch (error) {
|
|
789
|
+
secure.destroy();
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** Closes the connection. It is safe to call more than once. */
|
|
794
|
+
close() {
|
|
795
|
+
if (this.closed) return;
|
|
796
|
+
this.closed = true;
|
|
797
|
+
this.detach();
|
|
798
|
+
this.socket.destroy();
|
|
799
|
+
}
|
|
800
|
+
attach() {
|
|
801
|
+
this.socket.on("data", this.handleData);
|
|
802
|
+
this.socket.on("error", this.handleError);
|
|
803
|
+
this.socket.on("close", this.handleClose);
|
|
804
|
+
}
|
|
805
|
+
detach() {
|
|
806
|
+
this.socket.off("data", this.handleData);
|
|
807
|
+
this.socket.off("error", this.handleError);
|
|
808
|
+
this.socket.off("close", this.handleClose);
|
|
809
|
+
}
|
|
810
|
+
handleData = (chunk) => {
|
|
811
|
+
this.buffer = this.buffer.length === 0 ? chunk : node_buffer.Buffer.concat([this.buffer, chunk]);
|
|
812
|
+
this.drain();
|
|
813
|
+
};
|
|
814
|
+
handleError = (error) => {
|
|
815
|
+
this.fail(error);
|
|
816
|
+
};
|
|
817
|
+
handleClose = () => {
|
|
818
|
+
this.fail(new SmtpSessionError(smtpSessionErrorKind.connectionClosed, "smtp: connection closed"));
|
|
819
|
+
};
|
|
820
|
+
drain() {
|
|
821
|
+
const pending = this.pending;
|
|
822
|
+
if (pending === void 0) return;
|
|
823
|
+
const index = this.buffer.indexOf(10);
|
|
824
|
+
if (index < 0) return;
|
|
825
|
+
const line = this.buffer.subarray(0, index + 1).toString("utf8");
|
|
826
|
+
this.buffer = this.buffer.subarray(index + 1);
|
|
827
|
+
this.pending = void 0;
|
|
828
|
+
pending.resolve(line);
|
|
829
|
+
}
|
|
830
|
+
fail(error) {
|
|
831
|
+
if (this.closed) return;
|
|
832
|
+
this.failure = error;
|
|
833
|
+
const pending = this.pending;
|
|
834
|
+
this.pending = void 0;
|
|
835
|
+
pending?.reject(error);
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
/** Waits for a socket to finish connecting or handshaking. */
|
|
839
|
+
function waitForReady(socket, event, timeout, signal, operation) {
|
|
840
|
+
return new Promise((resolve, reject) => {
|
|
841
|
+
let settled = false;
|
|
842
|
+
let timer;
|
|
843
|
+
let onAbort;
|
|
844
|
+
const cleanup = () => {
|
|
845
|
+
socket.off(event, onReady);
|
|
846
|
+
socket.off("error", onError);
|
|
847
|
+
socket.off("close", onClose);
|
|
848
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
849
|
+
if (onAbort !== void 0) signal?.removeEventListener("abort", onAbort);
|
|
850
|
+
};
|
|
851
|
+
const settle = (settleWith) => {
|
|
852
|
+
if (settled) return;
|
|
853
|
+
settled = true;
|
|
854
|
+
cleanup();
|
|
855
|
+
settleWith();
|
|
856
|
+
};
|
|
857
|
+
const onReady = () => settle(resolve);
|
|
858
|
+
const onError = (error) => settle(() => reject(error));
|
|
859
|
+
const onClose = () => settle(() => reject(new SmtpSessionError(smtpSessionErrorKind.connectionClosed, "smtp: connection closed before it was ready")));
|
|
860
|
+
if (signal?.aborted === true) {
|
|
861
|
+
settle(() => reject(abortError(signal)));
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
socket.once(event, onReady);
|
|
865
|
+
socket.once("error", onError);
|
|
866
|
+
socket.once("close", onClose);
|
|
867
|
+
timer = setTimeout(() => settle(() => reject(new SmtpTimeoutError(operation))), timeout);
|
|
868
|
+
onAbort = () => settle(() => reject(abortError(signal)));
|
|
869
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
/** Builds the error used when an operation observes an aborted signal. */
|
|
873
|
+
function abortError(signal) {
|
|
874
|
+
return new SmtpAbortError({ cause: signal?.reason });
|
|
875
|
+
}
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region src/internal/smtp/response.ts
|
|
878
|
+
/**
|
|
879
|
+
* SMTP reply parsing.
|
|
880
|
+
*
|
|
881
|
+
* @internal
|
|
882
|
+
*/
|
|
883
|
+
/** Reports whether a reply code indicates success (2xx). */
|
|
884
|
+
function isSuccess(code) {
|
|
885
|
+
return code >= 200 && code < 300;
|
|
886
|
+
}
|
|
887
|
+
/** Reports whether a reply code indicates an intermediate reply (3xx). */
|
|
888
|
+
function isIntermediate(code) {
|
|
889
|
+
return code >= 300 && code < 400;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Converts a non-success reply into an {@link SMTPError}.
|
|
893
|
+
*
|
|
894
|
+
* @param response - The reply to inspect.
|
|
895
|
+
* @returns An error for 4xx and 5xx replies, or `undefined` for 2xx and 3xx.
|
|
896
|
+
*/
|
|
897
|
+
function responseError(response) {
|
|
898
|
+
if (isSuccess(response.code) || isIntermediate(response.code)) return;
|
|
899
|
+
return new SMTPError({
|
|
900
|
+
code: response.code,
|
|
901
|
+
enhancedCode: response.enhancedCode === "" ? void 0 : response.enhancedCode,
|
|
902
|
+
message: response.message
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Extracts an RFC 3463 enhanced status code from the start of a reply text.
|
|
907
|
+
*
|
|
908
|
+
* @param message - The first line of a reply.
|
|
909
|
+
* @returns The `X.Y.Z` code, or an empty string when the text does not start
|
|
910
|
+
* with a valid code.
|
|
911
|
+
*/
|
|
912
|
+
function parseEnhancedCode(message) {
|
|
913
|
+
if (message.length < 5) return "";
|
|
914
|
+
const token = message.split(" ", 1)[0] ?? "";
|
|
915
|
+
const parts = token.split(".");
|
|
916
|
+
if (parts.length !== 3) return "";
|
|
917
|
+
for (const part of parts) if (!/^\d+$/.test(part)) return "";
|
|
918
|
+
return token;
|
|
919
|
+
}
|
|
920
|
+
//#endregion
|
|
921
|
+
//#region src/internal/smtp/dsn.ts
|
|
922
|
+
/**
|
|
923
|
+
* RFC 3461 Delivery Status Notification parameter formatting.
|
|
924
|
+
*
|
|
925
|
+
* Only the encoding direction is needed when building commands, but xtext
|
|
926
|
+
* parsing is included for symmetry and testing.
|
|
927
|
+
*
|
|
928
|
+
* @internal
|
|
929
|
+
*/
|
|
930
|
+
/** The maximum length of an `ENVID=` parameter, including the keyword. */
|
|
931
|
+
const MAX_ENVELOPE_ID_LENGTH = 100;
|
|
932
|
+
/** The maximum length of an `ORCPT=` parameter, including the keyword. */
|
|
933
|
+
const MAX_ORIGINAL_RECIPIENT_LENGTH = 500;
|
|
934
|
+
/**
|
|
935
|
+
* Encodes a value as RFC 3461 xtext.
|
|
936
|
+
*
|
|
937
|
+
* @param decoded - The value to encode.
|
|
938
|
+
* @returns The encoded value.
|
|
939
|
+
*/
|
|
940
|
+
function encodeXtext(decoded) {
|
|
941
|
+
let encoded = "";
|
|
942
|
+
for (let index = 0; index < decoded.length; index += 1) {
|
|
943
|
+
const code = decoded.charCodeAt(index);
|
|
944
|
+
if (code >= 33 && code <= 126 && code !== 43 && code !== 61) {
|
|
945
|
+
encoded += decoded[index];
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
encoded += `+${toHex(code >> 4)}${toHex(code & 15)}`;
|
|
949
|
+
}
|
|
950
|
+
return encoded;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Validates and upper-cases a `RET` value.
|
|
954
|
+
*
|
|
955
|
+
* @param value - `FULL` or `HDRS`, case-insensitively.
|
|
956
|
+
* @returns The canonical value.
|
|
957
|
+
* @throws `Error` When the value is not `FULL` or `HDRS`.
|
|
958
|
+
*/
|
|
959
|
+
function normalizeDsnReturn(value) {
|
|
960
|
+
if (/[\r\n]/.test(value)) throw new Error("dsn: RET contains a line break");
|
|
961
|
+
const normalized = value.toUpperCase();
|
|
962
|
+
if (normalized !== "FULL" && normalized !== "HDRS") throw new Error("dsn: RET must be FULL or HDRS");
|
|
963
|
+
return normalized;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Validates and upper-cases `NOTIFY` values.
|
|
967
|
+
*
|
|
968
|
+
* `NEVER` is valid only as the sole value.
|
|
969
|
+
*
|
|
970
|
+
* @param values - The values to normalize.
|
|
971
|
+
* @returns The canonical values.
|
|
972
|
+
* @throws `Error` When a value is invalid or `NEVER` is combined.
|
|
973
|
+
*/
|
|
974
|
+
function normalizeDsnNotify(values) {
|
|
975
|
+
if (values.length === 0) throw new Error("dsn: NOTIFY value is empty");
|
|
976
|
+
const normalized = [];
|
|
977
|
+
for (const value of values) {
|
|
978
|
+
if (value === "" || /[\r\n]/.test(value)) throw new Error("dsn: invalid NOTIFY value");
|
|
979
|
+
const upper = value.toUpperCase();
|
|
980
|
+
if (upper !== "NEVER" && upper !== "SUCCESS" && upper !== "FAILURE" && upper !== "DELAY") throw new Error(`dsn: invalid NOTIFY value ${JSON.stringify(value)}`);
|
|
981
|
+
normalized.push(upper);
|
|
982
|
+
}
|
|
983
|
+
if (normalized.includes("NEVER") && normalized.length !== 1) throw new Error("dsn: NOTIFY NEVER must appear alone");
|
|
984
|
+
return normalized;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Encodes a decoded envelope identifier as its `ENVID` wire value.
|
|
988
|
+
*
|
|
989
|
+
* @param decoded - The printable-ASCII envelope identifier.
|
|
990
|
+
* @returns The encoded value, without the `ENVID=` prefix.
|
|
991
|
+
* @throws `Error` When the value is empty, non-printable, or too long.
|
|
992
|
+
*/
|
|
993
|
+
function formatDsnEnvelopeId(decoded) {
|
|
994
|
+
if (decoded === "" || !isPrintableAscii(decoded)) throw new Error("dsn: invalid ENVID decoded value");
|
|
995
|
+
const wire = encodeXtext(decoded);
|
|
996
|
+
if (wire.length + 6 > MAX_ENVELOPE_ID_LENGTH) throw new Error(`dsn: ENVID exceeds ${MAX_ENVELOPE_ID_LENGTH} characters`);
|
|
997
|
+
return wire;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Encodes a decoded original recipient as its `ORCPT` wire value.
|
|
1001
|
+
*
|
|
1002
|
+
* @param addressType - The address type, for example `rfc822` or `utf-8`.
|
|
1003
|
+
* @param decoded - The decoded original recipient.
|
|
1004
|
+
* @param smtpUtf8 - Whether the session negotiated SMTPUTF8.
|
|
1005
|
+
* @returns The `address-type;encoded-address` value.
|
|
1006
|
+
* @throws `Error` When the address type or value is invalid.
|
|
1007
|
+
*/
|
|
1008
|
+
function formatDsnOriginalRecipient(addressType, decoded, smtpUtf8) {
|
|
1009
|
+
if (!isAtom(addressType)) throw new Error("dsn: invalid ORCPT address type");
|
|
1010
|
+
let encoded;
|
|
1011
|
+
if (addressType.toLowerCase() === "utf-8") encoded = encodeUtf8Orcpt(decoded, smtpUtf8);
|
|
1012
|
+
else {
|
|
1013
|
+
if (decoded === "" || !isPrintableAscii(decoded)) throw new Error("dsn: invalid ORCPT decoded address");
|
|
1014
|
+
encoded = encodeXtext(decoded);
|
|
1015
|
+
}
|
|
1016
|
+
const wire = `${addressType};${encoded}`;
|
|
1017
|
+
if (wire.length + 6 > MAX_ORIGINAL_RECIPIENT_LENGTH) throw new Error(`dsn: ORCPT exceeds ${MAX_ORIGINAL_RECIPIENT_LENGTH} characters`);
|
|
1018
|
+
return wire;
|
|
1019
|
+
}
|
|
1020
|
+
/** Encodes a UTF-8 original recipient using `\x{...}` escapes when needed. */
|
|
1021
|
+
function encodeUtf8Orcpt(address, smtpUtf8) {
|
|
1022
|
+
if (address === "" || /[\r\n]/.test(address)) throw new Error("dsn: invalid UTF-8 ORCPT address");
|
|
1023
|
+
let encoded = "";
|
|
1024
|
+
for (const char of address) {
|
|
1025
|
+
const code = char.codePointAt(0) ?? 0;
|
|
1026
|
+
if (code < 128 && isQChar(code)) {
|
|
1027
|
+
encoded += char;
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (code >= 128 && smtpUtf8) {
|
|
1031
|
+
encoded += char;
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
encoded += `\\x{${code.toString(16).toUpperCase()}}`;
|
|
1035
|
+
}
|
|
1036
|
+
return encoded;
|
|
1037
|
+
}
|
|
1038
|
+
/** Reports whether a string is non-empty printable US-ASCII. */
|
|
1039
|
+
function isPrintableAscii(value) {
|
|
1040
|
+
if (value === "") return false;
|
|
1041
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1042
|
+
const code = value.charCodeAt(index);
|
|
1043
|
+
if (code !== 9 && (code < 32 || code > 126)) return false;
|
|
1044
|
+
}
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
/** Reports whether a string is a valid RFC 3461 `atom`. */
|
|
1048
|
+
function isAtom(value) {
|
|
1049
|
+
if (value === "") return false;
|
|
1050
|
+
for (const char of value) {
|
|
1051
|
+
if (/[A-Za-z0-9]/.test(char) || "!#$%&'*+-/?^_`{|}~".includes(char)) continue;
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
/** Reports whether a code point may appear literally in a UTF-8 ORCPT. */
|
|
1057
|
+
function isQChar(code) {
|
|
1058
|
+
return code >= 33 && code <= 126 && code !== 92 && code !== 43 && code !== 61;
|
|
1059
|
+
}
|
|
1060
|
+
/** Converts one hexadecimal nibble to its uppercase character. */
|
|
1061
|
+
function toHex(nibble) {
|
|
1062
|
+
return nibble.toString(16).toUpperCase();
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Builds the `MAIL FROM` command for an envelope.
|
|
1066
|
+
*
|
|
1067
|
+
* @param context - The server capabilities.
|
|
1068
|
+
* @param envelope - The envelope.
|
|
1069
|
+
* @returns The command without a terminating CRLF.
|
|
1070
|
+
* @throws {@link SMTPError} When `REQUIRETLS` is requested but unavailable.
|
|
1071
|
+
* @throws {@link SmtpSessionError} When `DELIVERBY` is requested but unsupported.
|
|
1072
|
+
*/
|
|
1073
|
+
function buildMailFromCommand(context, envelope) {
|
|
1074
|
+
const params = [];
|
|
1075
|
+
if (context.hasExtension(smtpExtension.size) && envelope.size !== void 0 && envelope.size > 0) params.push(`SIZE=${envelope.size}`);
|
|
1076
|
+
if (envelope.bodyType === "8BITMIME" && context.hasExtension(smtpExtension.eightBitMime)) params.push("BODY=8BITMIME");
|
|
1077
|
+
if (envelope.bodyType === "BINARYMIME" && context.hasExtension(smtpExtension.binaryMime)) params.push("BODY=BINARYMIME");
|
|
1078
|
+
if (envelope.smtpUtf8 === true && context.hasExtension(smtpExtension.smtpUtf8)) params.push("SMTPUTF8");
|
|
1079
|
+
if (envelope.requireTls === true) {
|
|
1080
|
+
if (!context.isTls) throw new SMTPError({
|
|
1081
|
+
code: 550,
|
|
1082
|
+
enhancedCode: "5.7.30",
|
|
1083
|
+
message: "REQUIRETLS requires an active TLS session"
|
|
1084
|
+
});
|
|
1085
|
+
if (!context.hasExtension(smtpExtension.requireTls)) throw new SMTPError({
|
|
1086
|
+
code: 550,
|
|
1087
|
+
enhancedCode: "5.7.30",
|
|
1088
|
+
message: "REQUIRETLS support required"
|
|
1089
|
+
});
|
|
1090
|
+
params.push("REQUIRETLS");
|
|
1091
|
+
}
|
|
1092
|
+
if (envelope.deliveryBy !== void 0) {
|
|
1093
|
+
if (!context.hasExtension(smtpExtension.deliverBy)) throw new SmtpSessionError(smtpSessionErrorKind.deliveryByNotSupported, "smtp: server does not support DELIVERBY");
|
|
1094
|
+
const value = formatDeliveryBy(envelope.deliveryBy);
|
|
1095
|
+
if (envelope.deliveryBy.mode === "R") {
|
|
1096
|
+
const minimum = parseDeliveryByMinimum(context.getExtensionParam(smtpExtension.deliverBy));
|
|
1097
|
+
if (minimum > 0 && envelope.deliveryBy.seconds < minimum) throw new Error(`smtp: DELIVERYBY BY time ${envelope.deliveryBy.seconds} is below server minimum ${minimum}`);
|
|
1098
|
+
}
|
|
1099
|
+
params.push(`BY=${value}`);
|
|
1100
|
+
}
|
|
1101
|
+
if (envelope.auth !== void 0 && envelope.auth !== "") params.push(`AUTH=<${envelope.auth}>`);
|
|
1102
|
+
if (envelope.dsnRet !== void 0 && envelope.dsnRet !== "" && context.hasExtension(smtpExtension.dsn)) params.push(`RET=${normalizeDsnReturn(envelope.dsnRet)}`);
|
|
1103
|
+
if (envelope.envid !== void 0 && envelope.envid !== "" && context.hasExtension(smtpExtension.dsn)) params.push(`ENVID=${formatDsnEnvelopeId(envelope.envid)}`);
|
|
1104
|
+
for (const [name, value] of envelope.extensionParams ?? []) {
|
|
1105
|
+
if (name.toUpperCase() === "BY" && envelope.deliveryBy !== void 0) continue;
|
|
1106
|
+
params.push(value === "" ? name.toUpperCase() : `${name.toUpperCase()}=${value}`);
|
|
1107
|
+
}
|
|
1108
|
+
const command = `MAIL FROM:${envelope.from === void 0 ? "<>" : `<${mailboxToString(envelope.from)}>`}`;
|
|
1109
|
+
return params.length > 0 ? `${command} ${params.join(" ")}` : command;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Builds the `RCPT TO` command for one recipient.
|
|
1113
|
+
*
|
|
1114
|
+
* @param context - The server capabilities.
|
|
1115
|
+
* @param recipient - The recipient.
|
|
1116
|
+
* @param smtpUtf8 - Whether the envelope negotiated SMTPUTF8.
|
|
1117
|
+
* @returns The command without a terminating CRLF.
|
|
1118
|
+
* @throws `Error` When a DSN parameter is malformed.
|
|
1119
|
+
*/
|
|
1120
|
+
function buildRcptToCommand(context, recipient, smtpUtf8) {
|
|
1121
|
+
const params = [];
|
|
1122
|
+
if (context.hasExtension(smtpExtension.dsn)) {
|
|
1123
|
+
if (recipient.dsnNotify !== void 0 && recipient.dsnNotify.length > 0) params.push(`NOTIFY=${normalizeDsnNotify(recipient.dsnNotify).join(",")}`);
|
|
1124
|
+
if (recipient.dsnOrcpt !== void 0 && recipient.dsnOrcpt !== "") {
|
|
1125
|
+
const separator = recipient.dsnOrcpt.indexOf(";");
|
|
1126
|
+
if (separator < 0) throw new Error("smtp: invalid DSN ORCPT: expected address-type;address");
|
|
1127
|
+
const addressType = recipient.dsnOrcpt.slice(0, separator);
|
|
1128
|
+
const address = recipient.dsnOrcpt.slice(separator + 1);
|
|
1129
|
+
params.push(`ORCPT=${formatDsnOriginalRecipient(addressType, address, smtpUtf8)}`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const command = `RCPT TO:<${mailboxToString(recipient.address)}>`;
|
|
1133
|
+
return params.length > 0 ? `${command} ${params.join(" ")}` : command;
|
|
1134
|
+
}
|
|
1135
|
+
/** Builds the per-recipient outcome from a reply. */
|
|
1136
|
+
function recipientOutcome(recipient, response) {
|
|
1137
|
+
const address = mailboxToString(recipient.address);
|
|
1138
|
+
if (isSuccess(response.code)) return {
|
|
1139
|
+
address,
|
|
1140
|
+
accepted: true,
|
|
1141
|
+
response
|
|
1142
|
+
};
|
|
1143
|
+
return {
|
|
1144
|
+
address,
|
|
1145
|
+
accepted: false,
|
|
1146
|
+
response,
|
|
1147
|
+
error: new SMTPError({
|
|
1148
|
+
code: response.code,
|
|
1149
|
+
enhancedCode: response.enhancedCode === "" ? void 0 : response.enhancedCode,
|
|
1150
|
+
message: response.message
|
|
1151
|
+
})
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/** Formats a DELIVERBY value. */
|
|
1155
|
+
function formatDeliveryBy(deliveryBy) {
|
|
1156
|
+
const mode = deliveryBy.mode.toUpperCase();
|
|
1157
|
+
if (mode !== "N" && mode !== "R") throw new Error(`smtp: invalid DELIVERYBY mode ${JSON.stringify(deliveryBy.mode)}`);
|
|
1158
|
+
if (mode === "R" && deliveryBy.seconds <= 0) throw new Error("smtp: DELIVERYBY mode R requires seconds > 0");
|
|
1159
|
+
return `${deliveryBy.seconds};${mode}${deliveryBy.trace === true ? "T" : ""}`;
|
|
1160
|
+
}
|
|
1161
|
+
/** Parses a DELIVERBY minimum interval, returning 0 when absent or invalid. */
|
|
1162
|
+
function parseDeliveryByMinimum(value) {
|
|
1163
|
+
const trimmed = value.trim();
|
|
1164
|
+
if (!/^\d+$/.test(trimmed)) return 0;
|
|
1165
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
1166
|
+
return Number.isSafeInteger(parsed) ? parsed : 0;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Applies dot-stuffing to one chunk, carrying line-start state across chunks.
|
|
1170
|
+
*
|
|
1171
|
+
* @param chunk - The chunk to stuff.
|
|
1172
|
+
* @param atLineStart - Whether the chunk starts a line.
|
|
1173
|
+
* @returns The stuffed chunk and the next line-start state.
|
|
1174
|
+
*/
|
|
1175
|
+
function dotStuffChunk(chunk, atLineStart) {
|
|
1176
|
+
let output;
|
|
1177
|
+
let state = atLineStart;
|
|
1178
|
+
for (let index = 0; index < chunk.length; index += 1) {
|
|
1179
|
+
const byte = chunk[index] ?? 0;
|
|
1180
|
+
if (state && byte === 46) {
|
|
1181
|
+
output ??= Array.from(chunk.subarray(0, index));
|
|
1182
|
+
output.push(46);
|
|
1183
|
+
}
|
|
1184
|
+
output?.push(byte);
|
|
1185
|
+
state = byte === 10;
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
data: output === void 0 ? chunk : Uint8Array.from(output),
|
|
1189
|
+
atLineStart: state
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Extracts a message identifier from a final reply.
|
|
1194
|
+
*
|
|
1195
|
+
* Recognizes angle-bracketed identifiers, `queued as <id>`, and `id=<id>`.
|
|
1196
|
+
*
|
|
1197
|
+
* @param message - The reply text.
|
|
1198
|
+
* @returns The identifier, or an empty string.
|
|
1199
|
+
*/
|
|
1200
|
+
function extractMessageId(message) {
|
|
1201
|
+
const trimmed = message.trim();
|
|
1202
|
+
const start = trimmed.indexOf("<");
|
|
1203
|
+
if (start !== -1) {
|
|
1204
|
+
const end = trimmed.indexOf(">", start);
|
|
1205
|
+
if (end !== -1) return trimmed.slice(start, end + 1);
|
|
1206
|
+
}
|
|
1207
|
+
const lower = trimmed.toLowerCase();
|
|
1208
|
+
const queued = lower.indexOf("queued as ");
|
|
1209
|
+
if (queued !== -1) {
|
|
1210
|
+
const parts = trimmed.slice(queued + 10).trim().split(/\s+/);
|
|
1211
|
+
if ((parts[0] ?? "") !== "") return parts[0] ?? "";
|
|
1212
|
+
}
|
|
1213
|
+
const id = lower.indexOf("id=");
|
|
1214
|
+
if (id !== -1) {
|
|
1215
|
+
const parts = trimmed.slice(id + 3).trim().split(/\s+/);
|
|
1216
|
+
if ((parts[0] ?? "") !== "") return parts[0] ?? "";
|
|
1217
|
+
}
|
|
1218
|
+
return "";
|
|
1219
|
+
}
|
|
1220
|
+
//#endregion
|
|
1221
|
+
//#region src/internal/smtp/session.ts
|
|
1222
|
+
/**
|
|
1223
|
+
* The SMTP session: connect, EHLO, STARTTLS, AUTH, and the stateless commands.
|
|
1224
|
+
*
|
|
1225
|
+
* A session is bound to a single connection and is not safe for concurrent use.
|
|
1226
|
+
* Higher layers (the dialer and pool) own session lifecycle.
|
|
1227
|
+
*
|
|
1228
|
+
* @internal
|
|
1229
|
+
*/
|
|
1230
|
+
const DEFAULT_LOCAL_NAME$1 = "localhost";
|
|
1231
|
+
const DEFAULT_CONNECT_TIMEOUT$1 = 3e4;
|
|
1232
|
+
const DEFAULT_READ_TIMEOUT = 3e5;
|
|
1233
|
+
const DEFAULT_WRITE_TIMEOUT = 3e5;
|
|
1234
|
+
/** An SMTP session over one connection. */
|
|
1235
|
+
var SmtpSession = class {
|
|
1236
|
+
options;
|
|
1237
|
+
connection;
|
|
1238
|
+
extensions = /* @__PURE__ */ new Map();
|
|
1239
|
+
esmtp = false;
|
|
1240
|
+
authenticated = false;
|
|
1241
|
+
closed = false;
|
|
1242
|
+
greetingText = "";
|
|
1243
|
+
hostnameText = "";
|
|
1244
|
+
serverNameText = "";
|
|
1245
|
+
lastResponseValue;
|
|
1246
|
+
constructor(options) {
|
|
1247
|
+
this.options = {
|
|
1248
|
+
...options,
|
|
1249
|
+
localName: options.localName ?? DEFAULT_LOCAL_NAME$1,
|
|
1250
|
+
connectTimeout: options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT$1,
|
|
1251
|
+
readTimeout: options.readTimeout ?? DEFAULT_READ_TIMEOUT,
|
|
1252
|
+
writeTimeout: options.writeTimeout ?? DEFAULT_WRITE_TIMEOUT
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
/** The server hostname reported in the greeting. */
|
|
1256
|
+
get greeting() {
|
|
1257
|
+
return this.greetingText;
|
|
1258
|
+
}
|
|
1259
|
+
/** The server host as configured. */
|
|
1260
|
+
get serverName() {
|
|
1261
|
+
return this.serverNameText;
|
|
1262
|
+
}
|
|
1263
|
+
/** The most recent reply, or `undefined` before the first reply. */
|
|
1264
|
+
get lastResponse() {
|
|
1265
|
+
return this.lastResponseValue;
|
|
1266
|
+
}
|
|
1267
|
+
/** Whether the connection is protected by TLS. */
|
|
1268
|
+
get isTls() {
|
|
1269
|
+
return this.connection?.isTls ?? false;
|
|
1270
|
+
}
|
|
1271
|
+
/** Whether EHLO was accepted. */
|
|
1272
|
+
get isEsmtp() {
|
|
1273
|
+
return this.esmtp;
|
|
1274
|
+
}
|
|
1275
|
+
/** Whether authentication succeeded. */
|
|
1276
|
+
get isAuthenticated() {
|
|
1277
|
+
return this.authenticated;
|
|
1278
|
+
}
|
|
1279
|
+
/** Returns a copy of the advertised extensions. */
|
|
1280
|
+
extensionsSnapshot() {
|
|
1281
|
+
return new Map(this.extensions);
|
|
1282
|
+
}
|
|
1283
|
+
/** Reports whether the server advertised an extension. */
|
|
1284
|
+
hasExtension(name) {
|
|
1285
|
+
return this.extensions.has(name.toUpperCase());
|
|
1286
|
+
}
|
|
1287
|
+
/** Returns an extension's parameter, or an empty string. */
|
|
1288
|
+
getExtensionParam(name) {
|
|
1289
|
+
return this.extensions.get(name.toUpperCase()) ?? "";
|
|
1290
|
+
}
|
|
1291
|
+
/** Returns a read-only view of the server capabilities. */
|
|
1292
|
+
capabilities() {
|
|
1293
|
+
return capabilitiesFrom(this.extensions, this.esmtp, this.hostnameText);
|
|
1294
|
+
}
|
|
1295
|
+
/** Returns the maximum message size advertised via `SIZE`, or 0. */
|
|
1296
|
+
maxSize() {
|
|
1297
|
+
return this.capabilities().maxSize;
|
|
1298
|
+
}
|
|
1299
|
+
/** Connects over plain TCP and reads the greeting. */
|
|
1300
|
+
async connect(signal) {
|
|
1301
|
+
await this.open(false, signal);
|
|
1302
|
+
}
|
|
1303
|
+
/** Connects using implicit TLS and reads the greeting. */
|
|
1304
|
+
async connectTls(signal) {
|
|
1305
|
+
await this.open(true, signal);
|
|
1306
|
+
}
|
|
1307
|
+
/** Sends EHLO, falling back to HELO when EHLO is rejected. */
|
|
1308
|
+
async hello(signal) {
|
|
1309
|
+
this.requireConnection();
|
|
1310
|
+
this.ensureNotClosed();
|
|
1311
|
+
const response = await this.command(`EHLO ${this.options.localName}`, signal);
|
|
1312
|
+
if (isSuccess(response.code)) {
|
|
1313
|
+
this.esmtp = true;
|
|
1314
|
+
this.extensions = parseExtensions(response.lines);
|
|
1315
|
+
this.hostnameText = (response.lines[0] ?? "").split(" ")[0] ?? "";
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const fallback = await this.command(`HELO ${this.options.localName}`, signal);
|
|
1319
|
+
if (!isSuccess(fallback.code)) throw this.requireReplyError(fallback);
|
|
1320
|
+
this.esmtp = false;
|
|
1321
|
+
this.extensions = /* @__PURE__ */ new Map();
|
|
1322
|
+
this.hostnameText = "";
|
|
1323
|
+
}
|
|
1324
|
+
/** Upgrades the connection with STARTTLS. */
|
|
1325
|
+
async startTls(signal) {
|
|
1326
|
+
const connection = this.requireConnection();
|
|
1327
|
+
if (connection.isTls) throw new SmtpSessionError(smtpSessionErrorKind.tlsAlreadyActive, "smtp: TLS is already active");
|
|
1328
|
+
if (!this.hasExtension(smtpExtension.startTls)) throw new SmtpSessionError(smtpSessionErrorKind.tlsNotSupported, "smtp: server does not support STARTTLS");
|
|
1329
|
+
const response = await this.command("STARTTLS", signal);
|
|
1330
|
+
if (!isSuccess(response.code)) throw this.requireReplyError(response);
|
|
1331
|
+
await connection.upgradeToTls(this.tlsOptions(), this.options.connectTimeout, signal);
|
|
1332
|
+
this.extensions = /* @__PURE__ */ new Map();
|
|
1333
|
+
this.esmtp = false;
|
|
1334
|
+
this.hostnameText = "";
|
|
1335
|
+
}
|
|
1336
|
+
/** Authenticates using a mechanism advertised by the server. */
|
|
1337
|
+
async auth(signal) {
|
|
1338
|
+
this.requireConnection();
|
|
1339
|
+
const auth = this.options.auth;
|
|
1340
|
+
if (auth === void 0) throw new SmtpSessionError(smtpSessionErrorKind.noCredentials, "smtp: no authentication credentials configured");
|
|
1341
|
+
if (!this.hasExtension(smtpExtension.auth)) throw new SmtpSessionError(smtpSessionErrorKind.extensionNotSupported, "smtp: server does not support the AUTH extension");
|
|
1342
|
+
const mechanisms = this.getExtensionParam(smtpExtension.auth).split(/\s+/).filter((value) => value !== "");
|
|
1343
|
+
const mechanism = selectAuthMechanism(auth.mechanisms ?? [], mechanisms);
|
|
1344
|
+
if (mechanism === "") throw new SmtpSessionError(smtpSessionErrorKind.authFailed, "smtp: no supported authentication mechanism available");
|
|
1345
|
+
await this.authWithMechanism(mechanism, signal);
|
|
1346
|
+
}
|
|
1347
|
+
/** Authenticates using an explicit mechanism. */
|
|
1348
|
+
async authWithMechanism(mechanism, signal) {
|
|
1349
|
+
this.requireConnection();
|
|
1350
|
+
const auth = this.options.auth;
|
|
1351
|
+
if (auth === void 0) throw new SmtpSessionError(smtpSessionErrorKind.noCredentials, "smtp: no authentication credentials configured");
|
|
1352
|
+
switch (mechanism.toUpperCase()) {
|
|
1353
|
+
case "PLAIN":
|
|
1354
|
+
await this.authPlain(auth, signal);
|
|
1355
|
+
return;
|
|
1356
|
+
case "LOGIN":
|
|
1357
|
+
await this.authLogin(auth, signal);
|
|
1358
|
+
return;
|
|
1359
|
+
default: throw new SmtpSessionError(smtpSessionErrorKind.unsupportedMechanism, `smtp: unsupported authentication mechanism: ${mechanism}`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
/** Sends RSET. */
|
|
1363
|
+
async reset(signal) {
|
|
1364
|
+
const response = await this.command("RSET", signal);
|
|
1365
|
+
if (!isSuccess(response.code)) throw this.requireReplyError(response);
|
|
1366
|
+
}
|
|
1367
|
+
/** Sends NOOP. */
|
|
1368
|
+
async noop(signal) {
|
|
1369
|
+
const response = await this.command("NOOP", signal);
|
|
1370
|
+
if (!isSuccess(response.code)) throw this.requireReplyError(response);
|
|
1371
|
+
}
|
|
1372
|
+
/** Sends QUIT and closes the connection, ignoring reply failures. */
|
|
1373
|
+
async quit(signal) {
|
|
1374
|
+
const connection = this.requireConnection();
|
|
1375
|
+
try {
|
|
1376
|
+
await this.writeCommand("QUIT", signal);
|
|
1377
|
+
await this.readReply(signal);
|
|
1378
|
+
} catch {} finally {
|
|
1379
|
+
connection.close();
|
|
1380
|
+
this.connection = void 0;
|
|
1381
|
+
this.authenticated = false;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
/** Closes the connection. It is safe to call more than once. */
|
|
1385
|
+
close() {
|
|
1386
|
+
this.closed = true;
|
|
1387
|
+
this.connection?.close();
|
|
1388
|
+
this.connection = void 0;
|
|
1389
|
+
this.authenticated = false;
|
|
1390
|
+
}
|
|
1391
|
+
async open(implicitTls, signal) {
|
|
1392
|
+
this.ensureNotClosed();
|
|
1393
|
+
if (this.connection !== void 0) {
|
|
1394
|
+
this.connection.close();
|
|
1395
|
+
this.connection = void 0;
|
|
1396
|
+
}
|
|
1397
|
+
const options = this.connectionOptions();
|
|
1398
|
+
this.connection = implicitTls ? await LineConnection.openTls(options, signal) : await LineConnection.openTcp(options, signal);
|
|
1399
|
+
this.serverNameText = this.options.host;
|
|
1400
|
+
const response = await this.readReply(signal);
|
|
1401
|
+
if (!isSuccess(response.code)) {
|
|
1402
|
+
this.connection.close();
|
|
1403
|
+
this.connection = void 0;
|
|
1404
|
+
throw this.requireReplyError(response);
|
|
1405
|
+
}
|
|
1406
|
+
this.greetingText = response.message;
|
|
1407
|
+
}
|
|
1408
|
+
async authPlain(auth, signal) {
|
|
1409
|
+
const response = await this.command(`AUTH PLAIN ${encodePlainAuth(auth.username, auth.password)}`, signal);
|
|
1410
|
+
if (!isSuccess(response.code)) throw new SmtpSessionError(smtpSessionErrorKind.authFailed, `smtp: authentication failed: ${response.message}`);
|
|
1411
|
+
this.authenticated = true;
|
|
1412
|
+
}
|
|
1413
|
+
async authLogin(auth, signal) {
|
|
1414
|
+
const challenge = await this.command("AUTH LOGIN", signal);
|
|
1415
|
+
if (challenge.code !== 334) throw new SmtpSessionError(smtpSessionErrorKind.authFailed, `smtp: authentication failed: unexpected response ${challenge.code}`);
|
|
1416
|
+
const passwordChallenge = await this.command(encodeLoginAuth(auth.username), signal);
|
|
1417
|
+
if (passwordChallenge.code !== 334) throw new SmtpSessionError(smtpSessionErrorKind.authFailed, `smtp: authentication failed: unexpected response ${passwordChallenge.code}`);
|
|
1418
|
+
const response = await this.command(encodeLoginAuth(auth.password), signal);
|
|
1419
|
+
if (!isSuccess(response.code)) throw new SmtpSessionError(smtpSessionErrorKind.authFailed, `smtp: authentication failed: ${response.message}`);
|
|
1420
|
+
this.authenticated = true;
|
|
1421
|
+
}
|
|
1422
|
+
async command(command, signal) {
|
|
1423
|
+
await this.writeCommand(command, signal);
|
|
1424
|
+
return this.readReply(signal);
|
|
1425
|
+
}
|
|
1426
|
+
async writeCommand(command, signal) {
|
|
1427
|
+
const connection = this.requireConnection();
|
|
1428
|
+
this.options.debug?.("client", command);
|
|
1429
|
+
const verb = command.split(" ", 1)[0] ?? command;
|
|
1430
|
+
await connection.write(`${command}\r\n`, {
|
|
1431
|
+
signal,
|
|
1432
|
+
timeout: this.options.writeTimeout,
|
|
1433
|
+
operation: `write ${verb}`
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
async readReply(signal) {
|
|
1437
|
+
const connection = this.requireConnection();
|
|
1438
|
+
const lines = [];
|
|
1439
|
+
let code = 0;
|
|
1440
|
+
for (;;) {
|
|
1441
|
+
const line = (await connection.readLine({
|
|
1442
|
+
signal,
|
|
1443
|
+
timeout: this.options.readTimeout,
|
|
1444
|
+
operation: "read reply"
|
|
1445
|
+
})).replace(/\r?\n$/, "");
|
|
1446
|
+
this.options.debug?.("server", line);
|
|
1447
|
+
if (line.length < 4) throw new SmtpSessionError(smtpSessionErrorKind.unexpectedResponse, `smtp: malformed reply line ${JSON.stringify(line)}`);
|
|
1448
|
+
if (!/^\d{3}$/.test(line.slice(0, 3))) throw new SmtpSessionError(smtpSessionErrorKind.unexpectedResponse, `smtp: invalid reply code in ${JSON.stringify(line)}`);
|
|
1449
|
+
const lineCode = Number.parseInt(line.slice(0, 3), 10);
|
|
1450
|
+
if (code === 0) code = lineCode;
|
|
1451
|
+
else if (lineCode !== code) throw new SmtpSessionError(smtpSessionErrorKind.unexpectedResponse, "smtp: reply used inconsistent status codes");
|
|
1452
|
+
lines.push(line.length > 4 ? line.slice(4) : "");
|
|
1453
|
+
if (line[3] === " ") break;
|
|
1454
|
+
}
|
|
1455
|
+
const response = {
|
|
1456
|
+
code,
|
|
1457
|
+
message: lines.join("\n"),
|
|
1458
|
+
lines,
|
|
1459
|
+
enhancedCode: parseEnhancedCode(lines[0] ?? "")
|
|
1460
|
+
};
|
|
1461
|
+
this.lastResponseValue = response;
|
|
1462
|
+
return response;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Runs a mail transaction for an already-serialized message.
|
|
1466
|
+
*
|
|
1467
|
+
* When the message is a single buffer larger than 1 MiB and the server
|
|
1468
|
+
* advertises `CHUNKING`, a single BDAT transfer is used; otherwise the
|
|
1469
|
+
* payload is streamed with DATA and dot-stuffing.
|
|
1470
|
+
*/
|
|
1471
|
+
async send(envelope, data, options = {}, signal) {
|
|
1472
|
+
return this.runTransaction(envelope, data, options, true, signal);
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Runs a mail transaction for a raw message stream.
|
|
1476
|
+
*
|
|
1477
|
+
* Unlike {@link SmtpSession.send}, a large payload is not automatically sent
|
|
1478
|
+
* with BDAT; chunked BDAT is used only when `options.preferBdat` is set.
|
|
1479
|
+
*/
|
|
1480
|
+
async sendRaw(envelope, data, options = {}, signal) {
|
|
1481
|
+
return this.runTransaction(envelope, data, options, false, signal);
|
|
1482
|
+
}
|
|
1483
|
+
/** Sends RSET when a transaction fails, ignoring any further error. */
|
|
1484
|
+
async bestEffortReset(signal) {
|
|
1485
|
+
try {
|
|
1486
|
+
await this.writeCommand("RSET", signal);
|
|
1487
|
+
await this.readReply(signal);
|
|
1488
|
+
} catch {}
|
|
1489
|
+
}
|
|
1490
|
+
/** Writes raw bytes with the configured write deadline. */
|
|
1491
|
+
async writeBlock(data, signal) {
|
|
1492
|
+
await this.requireConnection().write(data, {
|
|
1493
|
+
signal,
|
|
1494
|
+
timeout: this.options.writeTimeout,
|
|
1495
|
+
operation: "write data"
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
async runTransaction(envelope, data, options, autoBdat, signal) {
|
|
1499
|
+
this.requireConnection();
|
|
1500
|
+
if (envelope.recipients.length === 0) throw new SmtpSessionError(smtpSessionErrorKind.noRecipients, "smtp: no recipients specified");
|
|
1501
|
+
const [result, accepted] = await this.sendEnvelope(envelope, options, signal);
|
|
1502
|
+
if (accepted === 0) {
|
|
1503
|
+
await this.bestEffortReset(signal);
|
|
1504
|
+
throw new SmtpTransactionError("smtp: transaction failed: all recipients rejected", result);
|
|
1505
|
+
}
|
|
1506
|
+
const chunks = toAsyncChunks(data);
|
|
1507
|
+
const knownSize = data instanceof Uint8Array ? data.byteLength : void 0;
|
|
1508
|
+
if (options.preferBdat === true && this.hasExtension(smtpExtension.chunking)) await this.sendBdatChunked(chunks, options.chunkSize ?? 65536, signal);
|
|
1509
|
+
else if (autoBdat && this.hasExtension(smtpExtension.chunking) && knownSize !== void 0 && knownSize > 1048576) await this.sendBdatSingle(data, signal);
|
|
1510
|
+
else {
|
|
1511
|
+
const response = await this.sendData(chunks, signal);
|
|
1512
|
+
result.response = response;
|
|
1513
|
+
result.messageId = extractMessageId(response.message);
|
|
1514
|
+
}
|
|
1515
|
+
result.success = true;
|
|
1516
|
+
return result;
|
|
1517
|
+
}
|
|
1518
|
+
async sendEnvelope(envelope, options, signal) {
|
|
1519
|
+
const smtpUtf8 = envelope.smtpUtf8 === true;
|
|
1520
|
+
buildMailFromCommand(this, envelope);
|
|
1521
|
+
for (const recipient of envelope.recipients) buildRcptToCommand(this, recipient, smtpUtf8);
|
|
1522
|
+
if (this.hasExtension(smtpExtension.pipelining)) return this.sendEnvelopePipelined(envelope, options, signal);
|
|
1523
|
+
return this.sendEnvelopeSequential(envelope, options, signal);
|
|
1524
|
+
}
|
|
1525
|
+
async sendEnvelopeSequential(envelope, options, signal) {
|
|
1526
|
+
const result = {
|
|
1527
|
+
success: false,
|
|
1528
|
+
messageId: "",
|
|
1529
|
+
recipients: []
|
|
1530
|
+
};
|
|
1531
|
+
const smtpUtf8 = envelope.smtpUtf8 === true;
|
|
1532
|
+
await this.writeCommand(buildMailFromCommand(this, envelope), signal);
|
|
1533
|
+
const mailResponse = await this.readReply(signal);
|
|
1534
|
+
if (!isSuccess(mailResponse.code)) throw this.requireReplyError(mailResponse);
|
|
1535
|
+
let accepted = 0;
|
|
1536
|
+
for (const recipient of envelope.recipients) {
|
|
1537
|
+
const outcome = await this.sendRcptTo(recipient, smtpUtf8, signal);
|
|
1538
|
+
result.recipients.push(outcome);
|
|
1539
|
+
if (outcome.accepted) accepted += 1;
|
|
1540
|
+
else if (options.requireAllRecipients === true) {
|
|
1541
|
+
await this.bestEffortReset(signal);
|
|
1542
|
+
throw new SmtpTransactionError(`smtp: transaction failed: recipient ${outcome.address} rejected`, result);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return [result, accepted];
|
|
1546
|
+
}
|
|
1547
|
+
async sendEnvelopePipelined(envelope, options, signal) {
|
|
1548
|
+
const smtpUtf8 = envelope.smtpUtf8 === true;
|
|
1549
|
+
const commands = [buildMailFromCommand(this, envelope), ...envelope.recipients.map((recipient) => buildRcptToCommand(this, recipient, smtpUtf8))];
|
|
1550
|
+
await this.writePipeline(commands, signal);
|
|
1551
|
+
const mailResponse = await this.readReply(signal);
|
|
1552
|
+
const result = {
|
|
1553
|
+
success: false,
|
|
1554
|
+
messageId: "",
|
|
1555
|
+
recipients: []
|
|
1556
|
+
};
|
|
1557
|
+
let accepted = 0;
|
|
1558
|
+
let firstRejected = "";
|
|
1559
|
+
for (const recipient of envelope.recipients) {
|
|
1560
|
+
let response;
|
|
1561
|
+
try {
|
|
1562
|
+
response = await this.readReply(signal);
|
|
1563
|
+
} catch (error) {
|
|
1564
|
+
result.recipients.push({
|
|
1565
|
+
address: mailboxToString(recipient.address),
|
|
1566
|
+
accepted: false,
|
|
1567
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
1568
|
+
});
|
|
1569
|
+
return [result, accepted];
|
|
1570
|
+
}
|
|
1571
|
+
const outcome = recipientOutcome(recipient, response);
|
|
1572
|
+
result.recipients.push(outcome);
|
|
1573
|
+
if (outcome.accepted) accepted += 1;
|
|
1574
|
+
else if (firstRejected === "") firstRejected = outcome.address;
|
|
1575
|
+
}
|
|
1576
|
+
if (!isSuccess(mailResponse.code)) throw this.requireReplyError(mailResponse);
|
|
1577
|
+
if (options.requireAllRecipients === true && firstRejected !== "") {
|
|
1578
|
+
await this.bestEffortReset(signal);
|
|
1579
|
+
throw new SmtpTransactionError(`smtp: transaction failed: recipient ${firstRejected} rejected`, result);
|
|
1580
|
+
}
|
|
1581
|
+
return [result, accepted];
|
|
1582
|
+
}
|
|
1583
|
+
async sendRcptTo(recipient, smtpUtf8, signal) {
|
|
1584
|
+
const address = mailboxToString(recipient.address);
|
|
1585
|
+
try {
|
|
1586
|
+
await this.writeCommand(buildRcptToCommand(this, recipient, smtpUtf8), signal);
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
return {
|
|
1589
|
+
address,
|
|
1590
|
+
accepted: false,
|
|
1591
|
+
error: contextError(error, `write RCPT TO for ${address}`)
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
try {
|
|
1595
|
+
return recipientOutcome(recipient, await this.readReply(signal));
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
return {
|
|
1598
|
+
address,
|
|
1599
|
+
accepted: false,
|
|
1600
|
+
error: contextError(error, `read RCPT TO for ${address}`)
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
async sendData(chunks, signal) {
|
|
1605
|
+
await this.writeCommand("DATA", signal);
|
|
1606
|
+
const intermediate = await this.readReply(signal);
|
|
1607
|
+
if (!isIntermediate(intermediate.code)) throw new SmtpSessionError(smtpSessionErrorKind.dataFailed, `smtp: DATA command failed: expected 354, got ${intermediate.code}`);
|
|
1608
|
+
let atLineStart = true;
|
|
1609
|
+
let previous = 0;
|
|
1610
|
+
let last = 0;
|
|
1611
|
+
let wrote = false;
|
|
1612
|
+
for await (const chunk of chunks) {
|
|
1613
|
+
const stuffed = dotStuffChunk(chunk, atLineStart);
|
|
1614
|
+
atLineStart = stuffed.atLineStart;
|
|
1615
|
+
if (chunk.length > 0) {
|
|
1616
|
+
if (chunk.length >= 2) previous = chunk[chunk.length - 2] ?? 0;
|
|
1617
|
+
else previous = last;
|
|
1618
|
+
last = chunk[chunk.length - 1] ?? 0;
|
|
1619
|
+
wrote = true;
|
|
1620
|
+
}
|
|
1621
|
+
await this.writeBlock(stuffed.data, signal);
|
|
1622
|
+
}
|
|
1623
|
+
if (wrote && (previous !== 13 || last !== 10)) await this.writeBlock("\r\n", signal);
|
|
1624
|
+
await this.writeBlock(".\r\n", signal);
|
|
1625
|
+
const final = await this.readReply(signal);
|
|
1626
|
+
if (!isSuccess(final.code)) throw this.requireReplyError(final);
|
|
1627
|
+
return final;
|
|
1628
|
+
}
|
|
1629
|
+
async sendBdatSingle(data, signal) {
|
|
1630
|
+
await this.writeCommand(`BDAT ${data.byteLength} LAST`, signal);
|
|
1631
|
+
await this.writeBlock(data, signal);
|
|
1632
|
+
const response = await this.readReply(signal);
|
|
1633
|
+
if (!isSuccess(response.code)) throw this.requireReplyError(response);
|
|
1634
|
+
}
|
|
1635
|
+
async sendBdatChunked(chunks, chunkSize, signal) {
|
|
1636
|
+
if (!Number.isInteger(chunkSize) || chunkSize <= 0) throw new Error("smtp: BDAT chunk size must be positive");
|
|
1637
|
+
const iterator = rechunk(chunks, chunkSize);
|
|
1638
|
+
const first = await iterator.next();
|
|
1639
|
+
if (first.done) {
|
|
1640
|
+
await this.writeCommand("BDAT 0 LAST", signal);
|
|
1641
|
+
const response = await this.readReply(signal);
|
|
1642
|
+
if (!isSuccess(response.code)) throw this.requireReplyError(response);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
let current = first.value;
|
|
1646
|
+
for (;;) {
|
|
1647
|
+
const next = await iterator.next();
|
|
1648
|
+
const isLast = next.done;
|
|
1649
|
+
await this.writeCommand(`BDAT ${current.byteLength}${isLast ? " LAST" : ""}`, signal);
|
|
1650
|
+
await this.writeBlock(current, signal);
|
|
1651
|
+
const response = await this.readReply(signal);
|
|
1652
|
+
if (!isSuccess(response.code)) throw this.requireReplyError(response);
|
|
1653
|
+
if (isLast) return;
|
|
1654
|
+
current = next.value;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
async writePipeline(commands, signal) {
|
|
1658
|
+
let payload = "";
|
|
1659
|
+
for (const command of commands) {
|
|
1660
|
+
this.options.debug?.("client", command);
|
|
1661
|
+
payload += `${command}\r\n`;
|
|
1662
|
+
}
|
|
1663
|
+
await this.writeBlock(payload, signal);
|
|
1664
|
+
}
|
|
1665
|
+
requireConnection() {
|
|
1666
|
+
if (this.connection === void 0) throw new SmtpSessionError(smtpSessionErrorKind.noConnection, "smtp: no connection established");
|
|
1667
|
+
return this.connection;
|
|
1668
|
+
}
|
|
1669
|
+
ensureNotClosed() {
|
|
1670
|
+
if (this.closed) throw new SmtpSessionError(smtpSessionErrorKind.clientClosed, "smtp: client is closed");
|
|
1671
|
+
}
|
|
1672
|
+
requireReplyError(response) {
|
|
1673
|
+
return responseError(response) ?? new SmtpSessionError(smtpSessionErrorKind.unexpectedResponse, `smtp: unexpected reply ${response.code}`);
|
|
1674
|
+
}
|
|
1675
|
+
connectionOptions() {
|
|
1676
|
+
return {
|
|
1677
|
+
host: this.options.host,
|
|
1678
|
+
port: this.options.port,
|
|
1679
|
+
connectTimeout: this.options.connectTimeout,
|
|
1680
|
+
readTimeout: this.options.readTimeout,
|
|
1681
|
+
localAddress: this.options.localAddress,
|
|
1682
|
+
tls: this.tlsOptions()
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
tlsOptions() {
|
|
1686
|
+
const tls = { ...this.options.tls };
|
|
1687
|
+
if (tls.servername === void 0 && (0, node_net.isIP)(this.options.host) === 0) tls.servername = this.options.host;
|
|
1688
|
+
return tls;
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
/** Normalizes a buffer or async iterable into an async iterable of chunks. */
|
|
1692
|
+
async function* toAsyncChunks(data) {
|
|
1693
|
+
if (data instanceof Uint8Array) {
|
|
1694
|
+
yield data;
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
yield* data;
|
|
1698
|
+
}
|
|
1699
|
+
/** Splits an async chunk stream into pieces no larger than `chunkSize`. */
|
|
1700
|
+
async function* rechunk(source, chunkSize) {
|
|
1701
|
+
for await (const chunk of source) for (let offset = 0; offset < chunk.length; offset += chunkSize) yield chunk.subarray(offset, Math.min(offset + chunkSize, chunk.length));
|
|
1702
|
+
}
|
|
1703
|
+
/** Adds a short context label to an unknown thrown value. */
|
|
1704
|
+
function contextError(error, label) {
|
|
1705
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1706
|
+
return new Error(`smtp: ${label}: ${message}`, { cause: error });
|
|
1707
|
+
}
|
|
1708
|
+
//#endregion
|
|
1709
|
+
//#region src/internal/smtp/dialer.ts
|
|
1710
|
+
const DEFAULT_LOCAL_NAME = "localhost";
|
|
1711
|
+
const DEFAULT_CONNECT_TIMEOUT = 3e4;
|
|
1712
|
+
const DEFAULT_IO_TIMEOUT = 3e5;
|
|
1713
|
+
/** Opens and initializes SMTP sessions. */
|
|
1714
|
+
var SmtpDialer = class {
|
|
1715
|
+
/** The configured host. */
|
|
1716
|
+
host;
|
|
1717
|
+
/** The configured port. */
|
|
1718
|
+
port;
|
|
1719
|
+
/** The EHLO name. */
|
|
1720
|
+
localName;
|
|
1721
|
+
/** The TCP connect deadline in milliseconds. */
|
|
1722
|
+
connectTimeout;
|
|
1723
|
+
/** The per-read deadline in milliseconds. */
|
|
1724
|
+
readTimeout;
|
|
1725
|
+
/** The per-write deadline in milliseconds. */
|
|
1726
|
+
writeTimeout;
|
|
1727
|
+
options;
|
|
1728
|
+
constructor(options) {
|
|
1729
|
+
this.options = options;
|
|
1730
|
+
this.host = options.host;
|
|
1731
|
+
this.port = options.port;
|
|
1732
|
+
this.localName = options.localName ?? DEFAULT_LOCAL_NAME;
|
|
1733
|
+
this.connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT;
|
|
1734
|
+
this.readTimeout = options.readTimeout ?? DEFAULT_IO_TIMEOUT;
|
|
1735
|
+
this.writeTimeout = options.writeTimeout ?? DEFAULT_IO_TIMEOUT;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Opens and initializes a session.
|
|
1739
|
+
*
|
|
1740
|
+
* On any failure the partially established session is closed before the
|
|
1741
|
+
* error is rethrown.
|
|
1742
|
+
*
|
|
1743
|
+
* @param signal - Cancels the dial.
|
|
1744
|
+
* @returns A connected, greeted, and optionally authenticated session.
|
|
1745
|
+
*/
|
|
1746
|
+
async dial(signal) {
|
|
1747
|
+
const session = new SmtpSession(this.sessionOptions());
|
|
1748
|
+
try {
|
|
1749
|
+
if (this.options.implicitTls === true) await session.connectTls(signal);
|
|
1750
|
+
else await session.connect(signal);
|
|
1751
|
+
await session.hello(signal);
|
|
1752
|
+
if (this.options.startTls === true && this.options.implicitTls !== true) {
|
|
1753
|
+
if (session.hasExtension(smtpExtension.startTls)) {
|
|
1754
|
+
await session.startTls(signal);
|
|
1755
|
+
await session.hello(signal);
|
|
1756
|
+
} else if (this.options.requireTls === true) throw new SmtpSessionError(smtpSessionErrorKind.tlsNotSupported, "smtp: server does not support STARTTLS");
|
|
1757
|
+
}
|
|
1758
|
+
if (this.options.auth !== void 0) await session.auth(signal);
|
|
1759
|
+
return session;
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
session.close();
|
|
1762
|
+
throw error;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
sessionOptions() {
|
|
1766
|
+
return {
|
|
1767
|
+
host: this.host,
|
|
1768
|
+
port: this.port,
|
|
1769
|
+
localName: this.localName,
|
|
1770
|
+
localAddress: this.options.localAddress,
|
|
1771
|
+
connectTimeout: this.connectTimeout,
|
|
1772
|
+
readTimeout: this.readTimeout,
|
|
1773
|
+
writeTimeout: this.writeTimeout,
|
|
1774
|
+
tls: this.options.tls,
|
|
1775
|
+
auth: this.options.auth,
|
|
1776
|
+
debug: this.options.debug
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
//#endregion
|
|
1781
|
+
//#region src/internal/smtp/pool.ts
|
|
1782
|
+
const DEFAULT_POOL_SIZE$1 = 5;
|
|
1783
|
+
/** A bounded pool of SMTP sessions. */
|
|
1784
|
+
var SmtpPool = class {
|
|
1785
|
+
/** The maximum number of live sessions. */
|
|
1786
|
+
size;
|
|
1787
|
+
dialer;
|
|
1788
|
+
healthCheck;
|
|
1789
|
+
idle = [];
|
|
1790
|
+
owned = /* @__PURE__ */ new Set();
|
|
1791
|
+
waiters = [];
|
|
1792
|
+
live = 0;
|
|
1793
|
+
closed = false;
|
|
1794
|
+
constructor(options) {
|
|
1795
|
+
this.dialer = options.dialer;
|
|
1796
|
+
this.size = options.size !== void 0 && options.size > 0 ? options.size : DEFAULT_POOL_SIZE$1;
|
|
1797
|
+
this.healthCheck = options.healthCheck !== false;
|
|
1798
|
+
}
|
|
1799
|
+
/** Whether the pool has been closed. */
|
|
1800
|
+
get isClosed() {
|
|
1801
|
+
return this.closed;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Acquires an initialized session, waiting for capacity when necessary.
|
|
1805
|
+
*
|
|
1806
|
+
* @param signal - Cancels capacity waiting and dialing.
|
|
1807
|
+
* @returns A session that is checked out to the caller.
|
|
1808
|
+
* @throws {@link SmtpSessionError} With kind `client-closed` when the pool is
|
|
1809
|
+
* closed.
|
|
1810
|
+
*/
|
|
1811
|
+
async acquire(signal) {
|
|
1812
|
+
for (;;) {
|
|
1813
|
+
if (signal?.aborted === true) throw new SmtpAbortError({ cause: signal.reason });
|
|
1814
|
+
const idle = this.idle.pop();
|
|
1815
|
+
if (idle !== void 0) {
|
|
1816
|
+
if (!this.healthCheck) return idle;
|
|
1817
|
+
try {
|
|
1818
|
+
await idle.noop(signal);
|
|
1819
|
+
return idle;
|
|
1820
|
+
} catch {
|
|
1821
|
+
this.destroy(idle);
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (this.closed) throw this.closedError();
|
|
1826
|
+
if (this.live < this.size) {
|
|
1827
|
+
this.live += 1;
|
|
1828
|
+
try {
|
|
1829
|
+
const session = await this.dialer.dial(signal);
|
|
1830
|
+
if (this.closed) {
|
|
1831
|
+
this.live -= 1;
|
|
1832
|
+
session.close();
|
|
1833
|
+
this.wake();
|
|
1834
|
+
throw this.closedError();
|
|
1835
|
+
}
|
|
1836
|
+
this.owned.add(session);
|
|
1837
|
+
return session;
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
this.live -= 1;
|
|
1840
|
+
this.wake();
|
|
1841
|
+
throw error;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
await this.waitForSlot(signal);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Returns a session to the pool.
|
|
1849
|
+
*
|
|
1850
|
+
* A session not owned by this pool is closed and never admitted.
|
|
1851
|
+
*
|
|
1852
|
+
* @param session - The session to return.
|
|
1853
|
+
*/
|
|
1854
|
+
release(session) {
|
|
1855
|
+
if (!this.owned.has(session)) {
|
|
1856
|
+
session.close();
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (this.closed) {
|
|
1860
|
+
this.destroy(session);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
this.idle.push(session);
|
|
1864
|
+
this.wake();
|
|
1865
|
+
}
|
|
1866
|
+
/** Closes idle sessions and rejects callers waiting for capacity. */
|
|
1867
|
+
async close() {
|
|
1868
|
+
if (this.closed) return;
|
|
1869
|
+
this.closed = true;
|
|
1870
|
+
for (const session of this.idle.splice(0)) {
|
|
1871
|
+
this.owned.delete(session);
|
|
1872
|
+
this.live -= 1;
|
|
1873
|
+
try {
|
|
1874
|
+
await session.quit();
|
|
1875
|
+
} catch {
|
|
1876
|
+
session.close();
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
1880
|
+
this.detachAbort(waiter);
|
|
1881
|
+
waiter.reject(this.closedError());
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/** Sends a composed message using a pooled session. */
|
|
1885
|
+
async send(envelope, data, options = {}, signal) {
|
|
1886
|
+
const session = await this.acquire(signal);
|
|
1887
|
+
try {
|
|
1888
|
+
const result = await session.send(envelope, data, options, signal);
|
|
1889
|
+
this.release(session);
|
|
1890
|
+
return result;
|
|
1891
|
+
} catch (error) {
|
|
1892
|
+
this.destroy(session);
|
|
1893
|
+
throw error;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
/** Sends a raw message using a pooled session. */
|
|
1897
|
+
async sendRaw(envelope, data, options = {}, signal) {
|
|
1898
|
+
const session = await this.acquire(signal);
|
|
1899
|
+
try {
|
|
1900
|
+
const result = await session.sendRaw(envelope, data, options, signal);
|
|
1901
|
+
this.release(session);
|
|
1902
|
+
return result;
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
this.destroy(session);
|
|
1905
|
+
throw error;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
destroy(session) {
|
|
1909
|
+
if (!this.owned.delete(session)) {
|
|
1910
|
+
session.close();
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
this.live -= 1;
|
|
1914
|
+
session.close();
|
|
1915
|
+
this.wake();
|
|
1916
|
+
}
|
|
1917
|
+
waitForSlot(signal) {
|
|
1918
|
+
return new Promise((resolve, reject) => {
|
|
1919
|
+
const waiter = {
|
|
1920
|
+
resolve,
|
|
1921
|
+
reject,
|
|
1922
|
+
signal
|
|
1923
|
+
};
|
|
1924
|
+
if (signal !== void 0) {
|
|
1925
|
+
if (signal.aborted) {
|
|
1926
|
+
reject(new SmtpAbortError({ cause: signal.reason }));
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
waiter.onAbort = () => {
|
|
1930
|
+
const index = this.waiters.indexOf(waiter);
|
|
1931
|
+
if (index >= 0) this.waiters.splice(index, 1);
|
|
1932
|
+
reject(new SmtpAbortError({ cause: signal.reason }));
|
|
1933
|
+
};
|
|
1934
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
1935
|
+
}
|
|
1936
|
+
this.waiters.push(waiter);
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
wake() {
|
|
1940
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
1941
|
+
this.detachAbort(waiter);
|
|
1942
|
+
waiter.resolve();
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
detachAbort(waiter) {
|
|
1946
|
+
if (waiter.onAbort !== void 0) waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
1947
|
+
}
|
|
1948
|
+
closedError() {
|
|
1949
|
+
return new SmtpSessionError(smtpSessionErrorKind.clientClosed, "smtp: pool is closed");
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
//#endregion
|
|
1953
|
+
//#region src/internal/headers.ts
|
|
1954
|
+
/** The recommended maximum line length for header fields. */
|
|
1955
|
+
const RECOMMENDED_LINE_LENGTH = 78;
|
|
1956
|
+
/**
|
|
1957
|
+
* Validates a header field name.
|
|
1958
|
+
*
|
|
1959
|
+
* @throws `Error` when the name is empty or contains characters outside the `ftext` set defined by
|
|
1960
|
+
* RFC 5322.
|
|
1961
|
+
* @internal
|
|
1962
|
+
*/
|
|
1963
|
+
function validateHeaderName(name) {
|
|
1964
|
+
if (!/^[!-9;-~]+$/.test(name)) throw new Error(`mail: invalid header name: ${JSON.stringify(name)}`);
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Validates a header field value.
|
|
1968
|
+
*
|
|
1969
|
+
* @throws `Error` when the value contains a line break or a control character.
|
|
1970
|
+
* @internal
|
|
1971
|
+
*/
|
|
1972
|
+
function validateHeaderValue(value) {
|
|
1973
|
+
if (/[\r\n]/.test(value)) throw new Error("mail: header value contains a line break");
|
|
1974
|
+
if (hasControlCharacter(value)) throw new Error("mail: header value contains a control character");
|
|
1975
|
+
}
|
|
1976
|
+
/** Reports whether a value contains a control character other than tab. */
|
|
1977
|
+
function hasControlCharacter(value) {
|
|
1978
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1979
|
+
const code = value.charCodeAt(index);
|
|
1980
|
+
if (code < 32 && code !== 9 || code === 127) return true;
|
|
1981
|
+
}
|
|
1982
|
+
return false;
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Validates a header field.
|
|
1986
|
+
*
|
|
1987
|
+
* @throws `Error` when the name or value is invalid.
|
|
1988
|
+
* @internal
|
|
1989
|
+
*/
|
|
1990
|
+
function validateHeader(name, value) {
|
|
1991
|
+
validateHeaderName(name);
|
|
1992
|
+
validateHeaderValue(value);
|
|
1993
|
+
}
|
|
1994
|
+
/** Returns the first value for a header name, compared case-insensitively. */
|
|
1995
|
+
function getHeader(headers, name) {
|
|
1996
|
+
const lower = name.toLowerCase();
|
|
1997
|
+
for (const header of headers) if (header.name.toLowerCase() === lower) return header.value;
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Serializes header fields into a CRLF-terminated header block.
|
|
2001
|
+
*
|
|
2002
|
+
* Long values are folded at whitespace near the recommended line length. A value that contains no
|
|
2003
|
+
* whitespace is never split, so encodings such as RFC 2047 encoded words survive intact.
|
|
2004
|
+
*
|
|
2005
|
+
* @internal
|
|
2006
|
+
*/
|
|
2007
|
+
function serializeHeaders(headers) {
|
|
2008
|
+
let output = "";
|
|
2009
|
+
for (const header of headers) output += foldHeader(header.name, header.value);
|
|
2010
|
+
return output;
|
|
2011
|
+
}
|
|
2012
|
+
/** Folds a single header field at whitespace near the recommended line length. */
|
|
2013
|
+
function foldHeader(name, value) {
|
|
2014
|
+
const prefix = `${name}: `;
|
|
2015
|
+
if (prefix.length + value.length <= RECOMMENDED_LINE_LENGTH) return `${prefix}${value}\r\n`;
|
|
2016
|
+
const words = value.split(/[ \t]+/).filter((word) => word !== "");
|
|
2017
|
+
const lines = [];
|
|
2018
|
+
let current = `${name}:`;
|
|
2019
|
+
let hasWord = false;
|
|
2020
|
+
for (const word of words) {
|
|
2021
|
+
const candidate = `${current} ${word}`;
|
|
2022
|
+
if (hasWord && candidate.length > RECOMMENDED_LINE_LENGTH) {
|
|
2023
|
+
lines.push(current);
|
|
2024
|
+
current = ` ${word}`;
|
|
2025
|
+
} else {
|
|
2026
|
+
current = candidate;
|
|
2027
|
+
hasWord = true;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
lines.push(current);
|
|
2031
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
2032
|
+
}
|
|
2033
|
+
//#endregion
|
|
2034
|
+
//#region src/internal/mime.ts
|
|
2035
|
+
/**
|
|
2036
|
+
* MIME serialization helpers.
|
|
2037
|
+
*
|
|
2038
|
+
* This module is internal to the SDK. It builds the `multipart/alternative` and `multipart/mixed`
|
|
2039
|
+
* bodies that the {@link Message} builder emits, and formats attachment parts with Base64 transfer
|
|
2040
|
+
* encoding.
|
|
2041
|
+
*
|
|
2042
|
+
* @internal
|
|
2043
|
+
*/
|
|
2044
|
+
/** Converts LF and bare CR line endings to CRLF. */
|
|
2045
|
+
function normalizeLineEndings(value) {
|
|
2046
|
+
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\r\n");
|
|
2047
|
+
}
|
|
2048
|
+
/** Appends a trailing CRLF when the value does not already end with one. */
|
|
2049
|
+
function ensureTrailingCrlf(value) {
|
|
2050
|
+
return value.endsWith("\r\n") ? value : `${value}\r\n`;
|
|
2051
|
+
}
|
|
2052
|
+
/** Encodes bytes as Base64 wrapped at 76 characters with CRLF line endings. */
|
|
2053
|
+
function encodeBase64Lines(data) {
|
|
2054
|
+
if (data.length === 0) return "";
|
|
2055
|
+
const encoded = node_buffer.Buffer.from(data).toString("base64");
|
|
2056
|
+
const lines = [];
|
|
2057
|
+
for (let index = 0; index < encoded.length; index += 76) lines.push(encoded.slice(index, index + 76));
|
|
2058
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
2059
|
+
}
|
|
2060
|
+
/** Builds a `multipart/alternative` body from plain-text and HTML sources. */
|
|
2061
|
+
function buildMultipartAlternative(text, html) {
|
|
2062
|
+
const encoding = containsNonAscii(text) || containsNonAscii(html) ? "8bit" : "7bit";
|
|
2063
|
+
const boundary = generateBoundary();
|
|
2064
|
+
const parts = [];
|
|
2065
|
+
parts.push(renderTextPart(boundary, "text/plain; charset=utf-8", encoding, text));
|
|
2066
|
+
parts.push(renderTextPart(boundary, "text/html; charset=utf-8", encoding, html));
|
|
2067
|
+
parts.push(`--${boundary}--\r\n`);
|
|
2068
|
+
return {
|
|
2069
|
+
contentType: `multipart/alternative; boundary="${boundary}"`,
|
|
2070
|
+
contentTransferEncoding: encoding,
|
|
2071
|
+
data: parts.join("")
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Wraps a body and its attachments into a `multipart/mixed` body.
|
|
2076
|
+
*
|
|
2077
|
+
* The body part is emitted first, followed by one part per attachment. The caller is responsible
|
|
2078
|
+
* for removing the original body-level content headers, as required by MIME.
|
|
2079
|
+
*/
|
|
2080
|
+
function wrapAttachments(body, attachments) {
|
|
2081
|
+
const boundary = generateBoundary();
|
|
2082
|
+
const parts = [];
|
|
2083
|
+
parts.push(`--${boundary}\r\n`);
|
|
2084
|
+
parts.push(`Content-Type: ${body.contentType}\r\n`);
|
|
2085
|
+
parts.push(`Content-Transfer-Encoding: ${body.contentTransferEncoding}\r\n`);
|
|
2086
|
+
parts.push("\r\n");
|
|
2087
|
+
parts.push(ensureTrailingCrlf(body.data));
|
|
2088
|
+
for (const attachment of attachments) {
|
|
2089
|
+
parts.push(`--${boundary}\r\n`);
|
|
2090
|
+
parts.push(`Content-Type: ${attachment.contentType}\r\n`);
|
|
2091
|
+
parts.push("Content-Transfer-Encoding: base64\r\n");
|
|
2092
|
+
const disposition = attachment.inline === true ? "inline" : "attachment";
|
|
2093
|
+
parts.push(`Content-Disposition: ${formatDisposition(disposition, attachment.filename)}\r\n`);
|
|
2094
|
+
if (attachment.contentId !== void 0 && attachment.contentId !== "") parts.push(`Content-ID: <${attachment.contentId}>\r\n`);
|
|
2095
|
+
parts.push("\r\n");
|
|
2096
|
+
parts.push(encodeBase64Lines(attachment.data));
|
|
2097
|
+
}
|
|
2098
|
+
parts.push(`--${boundary}--\r\n`);
|
|
2099
|
+
return {
|
|
2100
|
+
contentType: `multipart/mixed; boundary="${boundary}"`,
|
|
2101
|
+
contentTransferEncoding: "7bit",
|
|
2102
|
+
data: parts.join("")
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
/** Renders one text part of a multipart body. */
|
|
2106
|
+
function renderTextPart(boundary, contentType, encoding, body) {
|
|
2107
|
+
return `--${boundary}\r\nContent-Type: ${contentType}\r\nContent-Transfer-Encoding: ${encoding}\r\n\r
|
|
2108
|
+
` + ensureTrailingCrlf(normalizeLineEndings(body));
|
|
2109
|
+
}
|
|
2110
|
+
/** Formats a `Content-Disposition` value with an optional filename. */
|
|
2111
|
+
function formatDisposition(disposition, filename) {
|
|
2112
|
+
if (filename === void 0 || filename === "") return disposition;
|
|
2113
|
+
if (containsNonAscii(filename)) return `${disposition}; filename*=utf-8''${encodeRfc2231(filename)}`;
|
|
2114
|
+
return `${disposition}; filename="${filename.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
2115
|
+
}
|
|
2116
|
+
/** Percent-encodes a value for an RFC 2231 extended parameter. */
|
|
2117
|
+
function encodeRfc2231(value) {
|
|
2118
|
+
let output = "";
|
|
2119
|
+
for (const byte of node_buffer.Buffer.from(value, "utf8")) {
|
|
2120
|
+
const char = String.fromCharCode(byte);
|
|
2121
|
+
if (/[A-Za-z0-9!#$&+\-.^_`|~]/.test(char)) output += char;
|
|
2122
|
+
else output += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
2123
|
+
}
|
|
2124
|
+
return output;
|
|
2125
|
+
}
|
|
2126
|
+
/** Generates a random MIME boundary. */
|
|
2127
|
+
function generateBoundary() {
|
|
2128
|
+
return `----=_mxraven_${(0, node_crypto.randomBytes)(16).toString("hex")}`;
|
|
2129
|
+
}
|
|
2130
|
+
//#endregion
|
|
2131
|
+
//#region src/message.ts
|
|
2132
|
+
/**
|
|
2133
|
+
* A mutable, chainable builder for an email message.
|
|
2134
|
+
*
|
|
2135
|
+
* Chained methods return the receiver, so a message is normally composed in a single expression. A
|
|
2136
|
+
* `Message` is not safe for concurrent use. It can be sent repeatedly; each send serializes the
|
|
2137
|
+
* current state.
|
|
2138
|
+
*
|
|
2139
|
+
* @example
|
|
2140
|
+
* ```ts
|
|
2141
|
+
* const message = new Message()
|
|
2142
|
+
* .from("Acme <noreply@acme.example>")
|
|
2143
|
+
* .to("customer@example.com")
|
|
2144
|
+
* .subject("Your receipt")
|
|
2145
|
+
* .text("Thanks for your order.")
|
|
2146
|
+
* .html("<p>Thanks for your order.</p>");
|
|
2147
|
+
* ```
|
|
2148
|
+
*
|
|
2149
|
+
* @public
|
|
2150
|
+
*/
|
|
2151
|
+
var Message = class {
|
|
2152
|
+
fromAddress = "";
|
|
2153
|
+
isNullSender = false;
|
|
2154
|
+
senderAddress = "";
|
|
2155
|
+
replyToAddress = "";
|
|
2156
|
+
toAddresses = [];
|
|
2157
|
+
ccAddresses = [];
|
|
2158
|
+
bccAddresses = [];
|
|
2159
|
+
subjectText = "";
|
|
2160
|
+
textBody;
|
|
2161
|
+
htmlBody;
|
|
2162
|
+
customHeaders = [];
|
|
2163
|
+
attachments = [];
|
|
2164
|
+
messageIdValue = "";
|
|
2165
|
+
inReplyToValue = "";
|
|
2166
|
+
referencesValue = [];
|
|
2167
|
+
dateValue;
|
|
2168
|
+
/** Sets the envelope sender and the `From` header. */
|
|
2169
|
+
from(address) {
|
|
2170
|
+
this.fromAddress = address;
|
|
2171
|
+
return this;
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Uses a null reverse-path while keeping the `From` header.
|
|
2175
|
+
*
|
|
2176
|
+
* It is intended for bounce and other auto-generated messages. A `From` header is still required
|
|
2177
|
+
* for a valid message.
|
|
2178
|
+
*/
|
|
2179
|
+
nullSender() {
|
|
2180
|
+
this.isNullSender = true;
|
|
2181
|
+
return this;
|
|
2182
|
+
}
|
|
2183
|
+
/** Sets the `Sender` header, required when `From` contains more than one mailbox. */
|
|
2184
|
+
sender(address) {
|
|
2185
|
+
this.senderAddress = address;
|
|
2186
|
+
return this;
|
|
2187
|
+
}
|
|
2188
|
+
/** Sets the `Reply-To` header. */
|
|
2189
|
+
replyTo(address) {
|
|
2190
|
+
this.replyToAddress = address;
|
|
2191
|
+
return this;
|
|
2192
|
+
}
|
|
2193
|
+
/** Adds envelope and `To` header recipients. */
|
|
2194
|
+
to(addresses) {
|
|
2195
|
+
this.toAddresses.push(...toArray(addresses));
|
|
2196
|
+
return this;
|
|
2197
|
+
}
|
|
2198
|
+
/** Adds envelope and `Cc` header recipients. */
|
|
2199
|
+
cc(addresses) {
|
|
2200
|
+
this.ccAddresses.push(...toArray(addresses));
|
|
2201
|
+
return this;
|
|
2202
|
+
}
|
|
2203
|
+
/** Adds envelope recipients without a visible `Bcc` header. */
|
|
2204
|
+
bcc(addresses) {
|
|
2205
|
+
this.bccAddresses.push(...toArray(addresses));
|
|
2206
|
+
return this;
|
|
2207
|
+
}
|
|
2208
|
+
/** Sets the `Subject` header. Non-ASCII subjects are RFC 2047 encoded. */
|
|
2209
|
+
subject(subject) {
|
|
2210
|
+
this.subjectText = subject;
|
|
2211
|
+
return this;
|
|
2212
|
+
}
|
|
2213
|
+
/** Sets the `Message-ID` header. The value is wrapped in angle brackets when needed. */
|
|
2214
|
+
messageId(id) {
|
|
2215
|
+
this.messageIdValue = id;
|
|
2216
|
+
return this;
|
|
2217
|
+
}
|
|
2218
|
+
/** Sets the `In-Reply-To` header for threading. */
|
|
2219
|
+
inReplyTo(id) {
|
|
2220
|
+
this.inReplyToValue = id;
|
|
2221
|
+
return this;
|
|
2222
|
+
}
|
|
2223
|
+
/** Sets the `References` header for threading. */
|
|
2224
|
+
references(ids) {
|
|
2225
|
+
this.referencesValue.push(...toArray(ids));
|
|
2226
|
+
return this;
|
|
2227
|
+
}
|
|
2228
|
+
/** Sets the `Date` header. When unset, the time of sending is used. */
|
|
2229
|
+
date(date) {
|
|
2230
|
+
this.dateValue = date;
|
|
2231
|
+
return this;
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Sets the plain-text body.
|
|
2235
|
+
*
|
|
2236
|
+
* When both a text and an HTML body are set, the message is sent as `multipart/alternative`.
|
|
2237
|
+
*/
|
|
2238
|
+
text(body) {
|
|
2239
|
+
this.textBody = body;
|
|
2240
|
+
return this;
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Sets the HTML body.
|
|
2244
|
+
*
|
|
2245
|
+
* When both a text and an HTML body are set, the message is sent as `multipart/alternative`.
|
|
2246
|
+
*/
|
|
2247
|
+
html(body) {
|
|
2248
|
+
this.htmlBody = body;
|
|
2249
|
+
return this;
|
|
2250
|
+
}
|
|
2251
|
+
/** Appends a custom header. */
|
|
2252
|
+
header(name, value) {
|
|
2253
|
+
this.customHeaders.push({
|
|
2254
|
+
name,
|
|
2255
|
+
value
|
|
2256
|
+
});
|
|
2257
|
+
return this;
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Appends an attachment.
|
|
2261
|
+
*
|
|
2262
|
+
* The data is retained by reference until the message is built. Callers must not mutate it in the
|
|
2263
|
+
* meantime.
|
|
2264
|
+
*/
|
|
2265
|
+
attach(attachment) {
|
|
2266
|
+
this.attachments.push(attachment);
|
|
2267
|
+
return this;
|
|
2268
|
+
}
|
|
2269
|
+
/** Appends a file attachment with an `application/octet-stream` content type. */
|
|
2270
|
+
attachFile(filename, data) {
|
|
2271
|
+
return this.attach({
|
|
2272
|
+
filename,
|
|
2273
|
+
data
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
/** Appends an inline attachment referenced by `contentId`, for example from `cid:` HTML. */
|
|
2277
|
+
attachInline(filename, contentId, data) {
|
|
2278
|
+
return this.attach({
|
|
2279
|
+
filename,
|
|
2280
|
+
contentId,
|
|
2281
|
+
data,
|
|
2282
|
+
inline: true
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Serializes the current state into a transmittable message.
|
|
2287
|
+
*
|
|
2288
|
+
* The returned value is a snapshot: later mutations of this builder do not affect it. The caller
|
|
2289
|
+
* owns the returned object.
|
|
2290
|
+
*
|
|
2291
|
+
* @returns The built message and its envelope.
|
|
2292
|
+
* @throws `AggregateError` When one or more addresses or headers are invalid, or when required
|
|
2293
|
+
* fields are missing.
|
|
2294
|
+
* @internal
|
|
2295
|
+
*/
|
|
2296
|
+
build() {
|
|
2297
|
+
const problems = [];
|
|
2298
|
+
const from = this.parseOptional(this.fromAddress, "from", problems);
|
|
2299
|
+
const sender = this.parseOptional(this.senderAddress, "sender", problems);
|
|
2300
|
+
const replyTo = this.parseOptional(this.replyToAddress, "reply-to", problems);
|
|
2301
|
+
const to = this.parseMany(this.toAddresses, "to", problems);
|
|
2302
|
+
const cc = this.parseMany(this.ccAddresses, "cc", problems);
|
|
2303
|
+
const bcc = this.parseMany(this.bccAddresses, "bcc", problems);
|
|
2304
|
+
const recipients = [
|
|
2305
|
+
...to,
|
|
2306
|
+
...cc,
|
|
2307
|
+
...bcc
|
|
2308
|
+
];
|
|
2309
|
+
if (from === void 0 && !this.isNullSender && getHeader(this.customHeaders, "From") === void 0) problems.push(/* @__PURE__ */ new Error("mail: from address is required"));
|
|
2310
|
+
if (recipients.length === 0) problems.push(/* @__PURE__ */ new Error("mail: at least one recipient is required"));
|
|
2311
|
+
const headers = [];
|
|
2312
|
+
if (from !== void 0) headers.push({
|
|
2313
|
+
name: "From",
|
|
2314
|
+
value: formatAddress(from)
|
|
2315
|
+
});
|
|
2316
|
+
if (sender !== void 0) headers.push({
|
|
2317
|
+
name: "Sender",
|
|
2318
|
+
value: formatAddress(sender)
|
|
2319
|
+
});
|
|
2320
|
+
if (replyTo !== void 0) headers.push({
|
|
2321
|
+
name: "Reply-To",
|
|
2322
|
+
value: formatAddress(replyTo)
|
|
2323
|
+
});
|
|
2324
|
+
if (to.length > 0) headers.push({
|
|
2325
|
+
name: "To",
|
|
2326
|
+
value: formatAddressList(to)
|
|
2327
|
+
});
|
|
2328
|
+
if (cc.length > 0) headers.push({
|
|
2329
|
+
name: "Cc",
|
|
2330
|
+
value: formatAddressList(cc)
|
|
2331
|
+
});
|
|
2332
|
+
if (this.subjectText !== "") headers.push({
|
|
2333
|
+
name: "Subject",
|
|
2334
|
+
value: encodeHeaderValue(this.subjectText)
|
|
2335
|
+
});
|
|
2336
|
+
if (this.messageIdValue !== "") headers.push({
|
|
2337
|
+
name: "Message-ID",
|
|
2338
|
+
value: wrapAngle(this.messageIdValue)
|
|
2339
|
+
});
|
|
2340
|
+
if (this.inReplyToValue !== "") headers.push({
|
|
2341
|
+
name: "In-Reply-To",
|
|
2342
|
+
value: wrapAngle(this.inReplyToValue)
|
|
2343
|
+
});
|
|
2344
|
+
if (this.referencesValue.length > 0) headers.push({
|
|
2345
|
+
name: "References",
|
|
2346
|
+
value: this.referencesValue.map(wrapAngle).join(" ")
|
|
2347
|
+
});
|
|
2348
|
+
for (const header of this.customHeaders) try {
|
|
2349
|
+
validateHeader(header.name, header.value);
|
|
2350
|
+
headers.push({
|
|
2351
|
+
name: header.name,
|
|
2352
|
+
value: header.value
|
|
2353
|
+
});
|
|
2354
|
+
} catch (error) {
|
|
2355
|
+
problems.push(toError(error, `header ${header.name}`));
|
|
2356
|
+
}
|
|
2357
|
+
if (getHeader(headers, "Date") === void 0) headers.push({
|
|
2358
|
+
name: "Date",
|
|
2359
|
+
value: formatDate(this.dateValue ?? /* @__PURE__ */ new Date())
|
|
2360
|
+
});
|
|
2361
|
+
if (getHeader(headers, "Message-ID") === void 0) {
|
|
2362
|
+
const domain = from?.domain ?? recipients[0]?.domain ?? "localhost";
|
|
2363
|
+
headers.push({
|
|
2364
|
+
name: "Message-ID",
|
|
2365
|
+
value: `<${Date.now()}.${(0, node_crypto.randomUUID)()}@${domain}>`
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
const rendered = this.renderBody(problems);
|
|
2369
|
+
if (rendered !== void 0) {
|
|
2370
|
+
headers.push({
|
|
2371
|
+
name: "MIME-Version",
|
|
2372
|
+
value: "1.0"
|
|
2373
|
+
});
|
|
2374
|
+
headers.push({
|
|
2375
|
+
name: "Content-Type",
|
|
2376
|
+
value: rendered.contentType
|
|
2377
|
+
});
|
|
2378
|
+
headers.push({
|
|
2379
|
+
name: "Content-Transfer-Encoding",
|
|
2380
|
+
value: rendered.contentTransferEncoding
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
if (problems.length > 0) throw new AggregateError(problems, "mail: build message");
|
|
2384
|
+
const headerBlock = serializeHeaders(headers);
|
|
2385
|
+
const data = node_buffer.Buffer.from(`${headerBlock}\r\n${rendered?.data ?? ""}`, "utf8");
|
|
2386
|
+
return {
|
|
2387
|
+
from: this.isNullSender ? void 0 : from,
|
|
2388
|
+
recipients,
|
|
2389
|
+
headerBlock,
|
|
2390
|
+
body: rendered?.data ?? "",
|
|
2391
|
+
data,
|
|
2392
|
+
smtpUtf8: requiresSmtpUtf8(from, recipients, headers),
|
|
2393
|
+
size: data.byteLength,
|
|
2394
|
+
builtAt: /* @__PURE__ */ new Date()
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
/** Renders the body, wrapping attachments in `multipart/mixed` when present. */
|
|
2398
|
+
renderBody(problems) {
|
|
2399
|
+
let bodyPart;
|
|
2400
|
+
if (this.textBody !== void 0 && this.htmlBody !== void 0) {
|
|
2401
|
+
const alternative = buildMultipartAlternative(this.textBody, this.htmlBody);
|
|
2402
|
+
bodyPart = {
|
|
2403
|
+
contentType: alternative.contentType,
|
|
2404
|
+
contentTransferEncoding: alternative.contentTransferEncoding,
|
|
2405
|
+
data: alternative.data
|
|
2406
|
+
};
|
|
2407
|
+
} else if (this.htmlBody !== void 0) bodyPart = renderTextBody("text/html; charset=utf-8", this.htmlBody);
|
|
2408
|
+
else if (this.textBody !== void 0) bodyPart = renderTextBody("text/plain; charset=utf-8", this.textBody);
|
|
2409
|
+
if (this.attachments.length === 0) return bodyPart;
|
|
2410
|
+
for (const attachment of this.attachments) {
|
|
2411
|
+
if (attachment.data.length === 0 && attachment.filename === void 0) problems.push(/* @__PURE__ */ new Error("mail: attachment requires data or a filename"));
|
|
2412
|
+
try {
|
|
2413
|
+
if (attachment.filename !== void 0) validateHeaderValue(attachment.filename);
|
|
2414
|
+
if (attachment.contentType !== void 0) validateHeaderValue(attachment.contentType);
|
|
2415
|
+
if (attachment.contentId !== void 0) validateHeaderValue(attachment.contentId);
|
|
2416
|
+
} catch (error) {
|
|
2417
|
+
problems.push(toError(error, `attachment ${attachment.filename ?? ""}`.trimEnd()));
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
const attachments = this.attachments.map(toMimeAttachment);
|
|
2421
|
+
if (bodyPart === void 0) bodyPart = {
|
|
2422
|
+
contentType: "text/plain; charset=utf-8",
|
|
2423
|
+
contentTransferEncoding: "7bit",
|
|
2424
|
+
data: ""
|
|
2425
|
+
};
|
|
2426
|
+
const mixed = wrapAttachments(bodyPart, attachments);
|
|
2427
|
+
return {
|
|
2428
|
+
contentType: mixed.contentType,
|
|
2429
|
+
contentTransferEncoding: mixed.contentTransferEncoding,
|
|
2430
|
+
data: mixed.data
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
/** Parses an optional address, recording a problem when invalid. */
|
|
2434
|
+
parseOptional(value, label, problems) {
|
|
2435
|
+
if (value.trim() === "") return;
|
|
2436
|
+
try {
|
|
2437
|
+
return parseAddress(value);
|
|
2438
|
+
} catch (error) {
|
|
2439
|
+
problems.push(toError(error, label));
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
/** Parses a list of addresses, recording problems for invalid entries. */
|
|
2444
|
+
parseMany(values, label, problems) {
|
|
2445
|
+
const parsed = [];
|
|
2446
|
+
for (const value of values) try {
|
|
2447
|
+
parsed.push(parseAddress(value));
|
|
2448
|
+
} catch (error) {
|
|
2449
|
+
problems.push(toError(error, label));
|
|
2450
|
+
}
|
|
2451
|
+
return parsed;
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
/** Normalizes a single address or array into an array. */
|
|
2455
|
+
function toArray(value) {
|
|
2456
|
+
return typeof value === "string" ? [value] : value;
|
|
2457
|
+
}
|
|
2458
|
+
/** Renders a simple text or HTML body. */
|
|
2459
|
+
function renderTextBody(contentType, body) {
|
|
2460
|
+
const normalized = normalizeLineEndings(body);
|
|
2461
|
+
return {
|
|
2462
|
+
contentType,
|
|
2463
|
+
contentTransferEncoding: containsNonAscii(normalized) ? "8bit" : "7bit",
|
|
2464
|
+
data: normalized
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
/** Renders a header value, RFC 2047-encoding it when it contains non-ASCII. */
|
|
2468
|
+
function encodeHeaderValue(value) {
|
|
2469
|
+
return containsNonAscii(value) ? encodeRfc2047(value) : value;
|
|
2470
|
+
}
|
|
2471
|
+
/** Wraps a message identifier in angle brackets when it is not already. */
|
|
2472
|
+
function wrapAngle(value) {
|
|
2473
|
+
return value.startsWith("<") ? value : `<${value}>`;
|
|
2474
|
+
}
|
|
2475
|
+
/** Converts an attachment into its MIME representation. */
|
|
2476
|
+
function toMimeAttachment(attachment) {
|
|
2477
|
+
return {
|
|
2478
|
+
filename: attachment.filename,
|
|
2479
|
+
contentType: attachment.contentType ?? "application/octet-stream",
|
|
2480
|
+
data: attachment.data,
|
|
2481
|
+
inline: attachment.inline,
|
|
2482
|
+
contentId: attachment.contentId
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
/** Reports whether the envelope requires the `SMTPUTF8` extension. */
|
|
2486
|
+
function requiresSmtpUtf8(from, recipients, headers) {
|
|
2487
|
+
const addresses = from === void 0 ? recipients : [from, ...recipients];
|
|
2488
|
+
for (const address of addresses) if (containsNonAscii(address.localPart) || containsNonAscii(address.domain)) return true;
|
|
2489
|
+
return headers.some((header) => containsNonAscii(header.value));
|
|
2490
|
+
}
|
|
2491
|
+
/** Formats a date using the RFC 5322 date-time syntax. */
|
|
2492
|
+
function formatDate(date) {
|
|
2493
|
+
const days = [
|
|
2494
|
+
"Sun",
|
|
2495
|
+
"Mon",
|
|
2496
|
+
"Tue",
|
|
2497
|
+
"Wed",
|
|
2498
|
+
"Thu",
|
|
2499
|
+
"Fri",
|
|
2500
|
+
"Sat"
|
|
2501
|
+
];
|
|
2502
|
+
const months = [
|
|
2503
|
+
"Jan",
|
|
2504
|
+
"Feb",
|
|
2505
|
+
"Mar",
|
|
2506
|
+
"Apr",
|
|
2507
|
+
"May",
|
|
2508
|
+
"Jun",
|
|
2509
|
+
"Jul",
|
|
2510
|
+
"Aug",
|
|
2511
|
+
"Sep",
|
|
2512
|
+
"Oct",
|
|
2513
|
+
"Nov",
|
|
2514
|
+
"Dec"
|
|
2515
|
+
];
|
|
2516
|
+
const offset = -date.getTimezoneOffset();
|
|
2517
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
2518
|
+
const absolute = Math.abs(offset);
|
|
2519
|
+
const zone = `${sign}${pad2(Math.floor(absolute / 60))}${pad2(absolute % 60)}`;
|
|
2520
|
+
const day = days[date.getDay()] ?? "Sun";
|
|
2521
|
+
const month = months[date.getMonth()] ?? "Jan";
|
|
2522
|
+
return `${day}, ${pad2(date.getDate())} ${month} ${date.getFullYear()} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())} ${zone}`;
|
|
2523
|
+
}
|
|
2524
|
+
/** Pads a number to two digits. */
|
|
2525
|
+
function pad2(value) {
|
|
2526
|
+
return String(value).padStart(2, "0");
|
|
2527
|
+
}
|
|
2528
|
+
/** Adds context to an unknown thrown value. */
|
|
2529
|
+
function toError(error, label) {
|
|
2530
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2531
|
+
return new Error(`mail: invalid ${label}: ${message}`, { cause: error });
|
|
2532
|
+
}
|
|
2533
|
+
//#endregion
|
|
2534
|
+
//#region src/result.ts
|
|
2535
|
+
/**
|
|
2536
|
+
* Extracts the mxRaven message reference from a final `DATA` reply.
|
|
2537
|
+
*
|
|
2538
|
+
* The submission service appends a message reference to its reply, for example
|
|
2539
|
+
* `250 2.0.0 accepted; message_ref=<uuid>`.
|
|
2540
|
+
*
|
|
2541
|
+
* @param message - The final reply text.
|
|
2542
|
+
* @returns The message reference, or an empty string when the reply has none.
|
|
2543
|
+
* @internal
|
|
2544
|
+
*/
|
|
2545
|
+
function parseMessageRef(message) {
|
|
2546
|
+
const index = message.indexOf("message_ref=");
|
|
2547
|
+
if (index < 0) return "";
|
|
2548
|
+
let value = message.slice(index + 12).trim();
|
|
2549
|
+
value = value.replace(/^</, "");
|
|
2550
|
+
const end = value.search(/[>;\s]/);
|
|
2551
|
+
if (end >= 0) value = value.slice(0, end);
|
|
2552
|
+
return value;
|
|
2553
|
+
}
|
|
2554
|
+
//#endregion
|
|
2555
|
+
//#region src/client.ts
|
|
2556
|
+
/** The default mxRaven SMTP submission port. */
|
|
2557
|
+
const defaultAddressPort = 587;
|
|
2558
|
+
const DEFAULT_POOL_SIZE = 5;
|
|
2559
|
+
/**
|
|
2560
|
+
* Submits mail to the mxRaven SMTP submission service.
|
|
2561
|
+
*
|
|
2562
|
+
* A client maintains a bounded pool of authenticated connections and is safe
|
|
2563
|
+
* for concurrent use. The submission service requires STARTTLS and SMTP AUTH,
|
|
2564
|
+
* so both are always used.
|
|
2565
|
+
*
|
|
2566
|
+
* @example
|
|
2567
|
+
* ```ts
|
|
2568
|
+
* const secret = process.env.MXRAVEN_SECRET;
|
|
2569
|
+
* if (secret === undefined || secret === "") {
|
|
2570
|
+
* throw new Error("MXRAVEN_SECRET is required");
|
|
2571
|
+
* }
|
|
2572
|
+
*
|
|
2573
|
+
* const client = new Client({
|
|
2574
|
+
* host: "smtp.mxraven.com",
|
|
2575
|
+
* username: "mxr_tx_ab12cd34ef56",
|
|
2576
|
+
* secret,
|
|
2577
|
+
* });
|
|
2578
|
+
* const result = await client.send(
|
|
2579
|
+
* new Message().from("noreply@acme.example").to("customer@example.com").text("Hi"),
|
|
2580
|
+
* );
|
|
2581
|
+
* await client.close();
|
|
2582
|
+
* ```
|
|
2583
|
+
*
|
|
2584
|
+
* @public
|
|
2585
|
+
*/
|
|
2586
|
+
var Client = class {
|
|
2587
|
+
pool;
|
|
2588
|
+
/**
|
|
2589
|
+
* @param options - The server address, credentials, and tuning options.
|
|
2590
|
+
* @throws `Error` When required options are missing or invalid.
|
|
2591
|
+
*/
|
|
2592
|
+
constructor(options) {
|
|
2593
|
+
const port = options.port ?? 587;
|
|
2594
|
+
if (options.host.trim() === "") throw new Error("mail: server host is required");
|
|
2595
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`mail: invalid server port ${port}`);
|
|
2596
|
+
if (options.username.trim() === "") throw new Error("mail: username must not be empty");
|
|
2597
|
+
if (options.secret === "") throw new Error("mail: secret must not be empty");
|
|
2598
|
+
if (options.poolSize !== void 0 && (!Number.isInteger(options.poolSize) || options.poolSize <= 0)) throw new Error(`mail: invalid pool size ${options.poolSize}`);
|
|
2599
|
+
const dialer = new SmtpDialer({
|
|
2600
|
+
host: options.host,
|
|
2601
|
+
port,
|
|
2602
|
+
localName: options.localName,
|
|
2603
|
+
connectTimeout: options.connectTimeout,
|
|
2604
|
+
readTimeout: options.readTimeout,
|
|
2605
|
+
writeTimeout: options.writeTimeout,
|
|
2606
|
+
tls: options.tls,
|
|
2607
|
+
auth: {
|
|
2608
|
+
username: options.username,
|
|
2609
|
+
password: options.secret
|
|
2610
|
+
},
|
|
2611
|
+
startTls: true,
|
|
2612
|
+
requireTls: true
|
|
2613
|
+
});
|
|
2614
|
+
this.pool = new SmtpPool({
|
|
2615
|
+
dialer,
|
|
2616
|
+
size: options.poolSize ?? DEFAULT_POOL_SIZE
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Sends a composed message.
|
|
2621
|
+
*
|
|
2622
|
+
* @param message - The message to submit. It may be sent more than once.
|
|
2623
|
+
* @param options - An optional cancellation signal.
|
|
2624
|
+
* @returns The server's result for the submission.
|
|
2625
|
+
* @throws {@link SMTPError} When the server rejects a command.
|
|
2626
|
+
* @throws {@link SMTPTransactionError} When every recipient is rejected; the
|
|
2627
|
+
* per-recipient detail is on {@link SMTPTransactionError.result}.
|
|
2628
|
+
*
|
|
2629
|
+
* @public
|
|
2630
|
+
*/
|
|
2631
|
+
async send(message, options = {}) {
|
|
2632
|
+
if (!(message instanceof Message)) throw new Error("mail: message is required");
|
|
2633
|
+
const built = message.build();
|
|
2634
|
+
return this.transact((signal) => this.pool.send(this.toEnvelope(built), built.data, {}, signal), options.signal);
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Streams an already serialized RFC 5322 message with an explicit envelope.
|
|
2638
|
+
*
|
|
2639
|
+
* The message is not parsed, so the caller is responsible for RFC 5322
|
|
2640
|
+
* correctness. Prefer this for large or pre-rendered messages.
|
|
2641
|
+
*
|
|
2642
|
+
* @param envelope - The SMTP envelope, independent of the message headers.
|
|
2643
|
+
* @param data - The raw message bytes, or an async stream of chunks.
|
|
2644
|
+
* @param options - An optional cancellation signal.
|
|
2645
|
+
* @returns The server's result for the submission.
|
|
2646
|
+
* @throws {@link SMTPError} When the server rejects a command.
|
|
2647
|
+
* @throws {@link SMTPTransactionError} When every recipient is rejected.
|
|
2648
|
+
*
|
|
2649
|
+
* @public
|
|
2650
|
+
*/
|
|
2651
|
+
async sendRaw(envelope, data, options = {}) {
|
|
2652
|
+
const smtpEnvelope = this.envelopeFromPublic(envelope);
|
|
2653
|
+
return this.transact((signal) => this.pool.sendRaw(smtpEnvelope, data, {}, signal), options.signal);
|
|
2654
|
+
}
|
|
2655
|
+
/** Releases the pooled connections. It is safe to call more than once. */
|
|
2656
|
+
async close() {
|
|
2657
|
+
await this.pool.close();
|
|
2658
|
+
}
|
|
2659
|
+
async transact(run, signal) {
|
|
2660
|
+
try {
|
|
2661
|
+
return toResult(await run(signal));
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
throw translateError(error);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
toEnvelope(built) {
|
|
2667
|
+
return {
|
|
2668
|
+
from: built.from,
|
|
2669
|
+
recipients: built.recipients.map((address) => ({ address })),
|
|
2670
|
+
size: built.size,
|
|
2671
|
+
smtpUtf8: built.smtpUtf8
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
envelopeFromPublic(envelope) {
|
|
2675
|
+
return {
|
|
2676
|
+
from: envelope.from === void 0 || envelope.from.trim() === "" ? void 0 : parseAddress(envelope.from),
|
|
2677
|
+
recipients: envelope.to.map((address) => ({ address: parseAddress(address) }))
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
};
|
|
2681
|
+
/** Converts an internal transaction result into the public result. */
|
|
2682
|
+
function toResult(transaction) {
|
|
2683
|
+
const response = transaction.response;
|
|
2684
|
+
return {
|
|
2685
|
+
messageRef: response === void 0 ? "" : parseMessageRef(response.message),
|
|
2686
|
+
code: response?.code ?? 0,
|
|
2687
|
+
message: response?.message ?? "",
|
|
2688
|
+
recipients: transaction.recipients.map((recipient) => ({
|
|
2689
|
+
address: recipient.address,
|
|
2690
|
+
accepted: recipient.accepted,
|
|
2691
|
+
error: recipient.error
|
|
2692
|
+
}))
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
/** Maps internal failures onto the public error surface. */
|
|
2696
|
+
function translateError(error) {
|
|
2697
|
+
if (error instanceof SmtpTransactionError) return new SMTPTransactionError(error.message, toResult(error.result));
|
|
2698
|
+
if (error instanceof Error) return error;
|
|
2699
|
+
return new Error(String(error));
|
|
2700
|
+
}
|
|
2701
|
+
//#endregion
|
|
2702
|
+
exports.Client = Client;
|
|
2703
|
+
exports.Message = Message;
|
|
2704
|
+
exports.SMTPError = SMTPError;
|
|
2705
|
+
exports.SMTPTransactionError = SMTPTransactionError;
|
|
2706
|
+
exports.defaultAddressPort = defaultAddressPort;
|
|
2707
|
+
|
|
2708
|
+
//# sourceMappingURL=index.cjs.map
|