@bumail/smtp 0.11.0 → 0.12.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/README.md +30 -2
- package/dist/client/connect.d.ts +18 -0
- package/dist/client/connect.d.ts.map +1 -0
- package/dist/client/inbox.d.ts +2 -0
- package/dist/client/inbox.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +594 -299
- package/dist/client/index.js.map +19 -11
- package/dist/client/options.d.ts +2 -0
- package/dist/client/options.d.ts.map +1 -1
- package/dist/client/pool/idle.d.ts +10 -0
- package/dist/client/pool/idle.d.ts.map +1 -0
- package/dist/client/pool/index.d.ts +18 -0
- package/dist/client/pool/index.d.ts.map +1 -0
- package/dist/client/pool/options.d.ts +26 -0
- package/dist/client/pool/options.d.ts.map +1 -0
- package/dist/client/pool/reset.d.ts +9 -0
- package/dist/client/pool/reset.d.ts.map +1 -0
- package/dist/client/pool/send.d.ts +8 -0
- package/dist/client/pool/send.d.ts.map +1 -0
- package/dist/client/pool/state.d.ts +28 -0
- package/dist/client/pool/state.d.ts.map +1 -0
- package/dist/client/send.d.ts +10 -1
- package/dist/client/send.d.ts.map +1 -1
- package/dist/client/session.d.ts +2 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/settings.d.ts +11 -5
- package/dist/client/settings.d.ts.map +1 -1
- package/dist/client/socket-events.d.ts +15 -0
- package/dist/client/socket-events.d.ts.map +1 -0
- package/dist/client/socket.d.ts +12 -2
- package/dist/client/socket.d.ts.map +1 -1
- package/dist/client/transaction.d.ts +9 -3
- package/dist/client/transaction.d.ts.map +1 -1
- package/docs/README.md +3 -3
- package/docs/guide.md +25 -3
- package/docs/roadmap.md +26 -5
- package/docs/troubleshooting.md +84 -0
- package/package.json +3 -3
package/dist/client/index.js
CHANGED
|
@@ -225,6 +225,20 @@ function contentOf(message, normalize, size) {
|
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
// src/protocol/ehlo.ts
|
|
229
|
+
function parseEhlo(lines) {
|
|
230
|
+
const extensions = new Map;
|
|
231
|
+
for (const line of lines.slice(1)) {
|
|
232
|
+
const text = line.trim();
|
|
233
|
+
const space = text.indexOf(" ");
|
|
234
|
+
const keyword = (space < 0 ? text : text.slice(0, space)).toUpperCase();
|
|
235
|
+
if (keyword === "")
|
|
236
|
+
continue;
|
|
237
|
+
extensions.set(keyword, space < 0 ? "" : text.slice(space + 1).trim());
|
|
238
|
+
}
|
|
239
|
+
return extensions;
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
// src/protocol/reply-reader.ts
|
|
229
243
|
var MAX_REPLY_LINES = 100;
|
|
230
244
|
var isDigit = (c) => c >= 48 && c <= 57;
|
|
@@ -316,20 +330,6 @@ function afterDot(error, taken) {
|
|
|
316
330
|
});
|
|
317
331
|
}
|
|
318
332
|
|
|
319
|
-
// src/protocol/ehlo.ts
|
|
320
|
-
function parseEhlo(lines) {
|
|
321
|
-
const extensions = new Map;
|
|
322
|
-
for (const line of lines.slice(1)) {
|
|
323
|
-
const text = line.trim();
|
|
324
|
-
const space = text.indexOf(" ");
|
|
325
|
-
const keyword = (space < 0 ? text : text.slice(0, space)).toUpperCase();
|
|
326
|
-
if (keyword === "")
|
|
327
|
-
continue;
|
|
328
|
-
extensions.set(keyword, space < 0 ? "" : text.slice(space + 1).trim());
|
|
329
|
-
}
|
|
330
|
-
return extensions;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
333
|
// src/client/session.ts
|
|
334
334
|
async function hello(socket, settings, allowHelo) {
|
|
335
335
|
const seconds = settings.timeouts.command;
|
|
@@ -408,206 +408,6 @@ async function open(socket, settings, tls) {
|
|
|
408
408
|
return { extensions, authenticated: settings.auth !== undefined };
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
-
// src/client/settings.ts
|
|
412
|
-
import { hostname as machineName } from "node:os";
|
|
413
|
-
|
|
414
|
-
// src/client/dsn.ts
|
|
415
|
-
var EVENTS = ["SUCCESS", "FAILURE", "DELAY"];
|
|
416
|
-
function dsnOf(given, to, fail) {
|
|
417
|
-
const { ret, envid, recipients } = given;
|
|
418
|
-
if (ret !== undefined && ret !== "FULL" && ret !== "HDRS")
|
|
419
|
-
throw fail(`ret must be 'FULL' or 'HDRS', not ${String(ret)}`);
|
|
420
|
-
if (envid !== undefined && (typeof envid !== "string" || !/^[ -~]{1,100}$/.test(envid) || encodeXtext(envid).length > 100))
|
|
421
|
-
throw fail("envid must be 1 to 100 printable ASCII characters, at most 100 once encoded as xtext");
|
|
422
|
-
const byAddress = new Map;
|
|
423
|
-
if (recipients !== undefined) {
|
|
424
|
-
if (!Array.isArray(recipients))
|
|
425
|
-
throw fail("recipients must be an array of { address, notify, orcpt }");
|
|
426
|
-
for (const entry of recipients) {
|
|
427
|
-
const address = entry?.address;
|
|
428
|
-
if (typeof address !== "string" || !to.includes(address))
|
|
429
|
-
throw fail(`recipients names ${JSON.stringify(address)}, which is not in to`);
|
|
430
|
-
if (byAddress.has(address))
|
|
431
|
-
throw fail(`recipients names ${address} twice`);
|
|
432
|
-
checkRecipient(entry, fail);
|
|
433
|
-
byAddress.set(address, entry);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return {
|
|
437
|
-
...ret ? { ret } : {},
|
|
438
|
-
...envid === undefined ? {} : { envid },
|
|
439
|
-
recipients: byAddress
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
function checkRecipient({ address, notify, orcpt }, fail) {
|
|
443
|
-
if (notify !== undefined && notify !== "NEVER") {
|
|
444
|
-
const events = Array.isArray(notify) ? notify : [];
|
|
445
|
-
if (events.length === 0 || events.some((e, i) => !EVENTS.includes(e) || events.indexOf(e) !== i))
|
|
446
|
-
throw fail(`notify of ${address} must be 'NEVER' or a list of 'SUCCESS', 'FAILURE', 'DELAY', each once`);
|
|
447
|
-
}
|
|
448
|
-
if (orcpt !== undefined) {
|
|
449
|
-
if (typeof orcpt !== "object" || orcpt === null || typeof orcpt.type !== "string" || !/^[A-Za-z0-9-]+$/.test(orcpt.type))
|
|
450
|
-
throw fail(`orcpt of ${address} must be { type, address }, the type a token`);
|
|
451
|
-
if (typeof orcpt.address !== "string" || orcpt.address === "")
|
|
452
|
-
throw fail(`orcpt.address of ${address} must be a non-empty string`);
|
|
453
|
-
const encoded = orcpt.type.toLowerCase() === "utf-8" ? utf8OrcptLength(orcpt.address) : encodeXtext(orcpt.address).length;
|
|
454
|
-
if (orcpt.type.length + 1 + encoded > 500)
|
|
455
|
-
throw fail(`orcpt of ${address} is over 500 characters once encoded`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
function mailDsn(dsn, offered) {
|
|
459
|
-
if (!offered)
|
|
460
|
-
return "";
|
|
461
|
-
let out = "";
|
|
462
|
-
if (dsn.ret)
|
|
463
|
-
out += ` RET=${dsn.ret}`;
|
|
464
|
-
if (dsn.envid !== undefined)
|
|
465
|
-
out += ` ENVID=${encodeXtext(dsn.envid)}`;
|
|
466
|
-
return out;
|
|
467
|
-
}
|
|
468
|
-
function orcptParameter({ type, address }, smtputf8) {
|
|
469
|
-
const kind = type.toLowerCase();
|
|
470
|
-
if (kind === "rfc822")
|
|
471
|
-
return /^[\x20-\x7e]+$/.test(address) ? ` ORCPT=${type};${encodeXtext(address)}` : "";
|
|
472
|
-
if (kind !== "utf-8") {
|
|
473
|
-
const value = `${type};${encodeXtext(address)}`;
|
|
474
|
-
return address.isWellFormed() ? ` ORCPT=${value}` : "";
|
|
475
|
-
}
|
|
476
|
-
const encoded = encodeUtf8Orcpt(address, smtputf8);
|
|
477
|
-
if (encoded === undefined)
|
|
478
|
-
return "";
|
|
479
|
-
const value = `${type};${encoded}`;
|
|
480
|
-
return new TextEncoder().encode(value).length > 500 ? "" : ` ORCPT=${value}`;
|
|
481
|
-
}
|
|
482
|
-
function rcptDsn(dsn, address, offered, smtputf8 = false) {
|
|
483
|
-
const entry = dsn.recipients.get(address);
|
|
484
|
-
if (!offered || !entry)
|
|
485
|
-
return "";
|
|
486
|
-
let out = "";
|
|
487
|
-
const { notify, orcpt } = entry;
|
|
488
|
-
if (notify !== undefined)
|
|
489
|
-
out += ` NOTIFY=${notify === "NEVER" ? "NEVER" : notify.join(",")}`;
|
|
490
|
-
if (orcpt)
|
|
491
|
-
out += orcptParameter(orcpt, smtputf8);
|
|
492
|
-
return out;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// src/client/settings.ts
|
|
496
|
-
var MAX_SECONDS = Math.floor(2147483647 / 1000);
|
|
497
|
-
var TIMEOUTS = {
|
|
498
|
-
connect: 30,
|
|
499
|
-
greeting: 300,
|
|
500
|
-
command: 300,
|
|
501
|
-
mail: 300,
|
|
502
|
-
rcpt: 300,
|
|
503
|
-
dataStart: 120,
|
|
504
|
-
dataBlock: 180,
|
|
505
|
-
dataEnd: 600
|
|
506
|
-
};
|
|
507
|
-
var invalid = (message) => new SmtpError2("INVALID_OPTION", `sendMail(): ${message}`);
|
|
508
|
-
function seconds(name, value, fallback) {
|
|
509
|
-
if (value === undefined)
|
|
510
|
-
return fallback;
|
|
511
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_SECONDS) {
|
|
512
|
-
throw invalid(`${name} must be a number of seconds above 0 and at most ${MAX_SECONDS}, not ${String(value)}`);
|
|
513
|
-
}
|
|
514
|
-
return value;
|
|
515
|
-
}
|
|
516
|
-
function timeoutsOf(given = {}) {
|
|
517
|
-
const out = {};
|
|
518
|
-
for (const [name, fallback] of Object.entries(TIMEOUTS)) {
|
|
519
|
-
out[name] = seconds(`timeouts.${name}`, given[name], fallback);
|
|
520
|
-
}
|
|
521
|
-
return out;
|
|
522
|
-
}
|
|
523
|
-
function address(value, allowNull) {
|
|
524
|
-
if (allowNull && value === "")
|
|
525
|
-
return "";
|
|
526
|
-
if (isMailbox(value))
|
|
527
|
-
return value;
|
|
528
|
-
const text = typeof value === "string" ? value : "";
|
|
529
|
-
const routed = typeof value === "string" && parsePath2(`<${text}>`, false);
|
|
530
|
-
if (routed) {
|
|
531
|
-
throw invalid(`${JSON.stringify(value)} holds a source route (@host:), which RFC 5321 says a client should not send: pass ${JSON.stringify(`${routed.local}@${text.slice(text.lastIndexOf("@") + 1)}`)} alone`);
|
|
532
|
-
}
|
|
533
|
-
throw invalid(`${JSON.stringify(value)} is not an address (local@domain${allowNull ? ", or '' for a bounce" : ""})`);
|
|
534
|
-
}
|
|
535
|
-
function heloOf(value, byMx) {
|
|
536
|
-
if (value !== undefined) {
|
|
537
|
-
if (!isHelloName(value))
|
|
538
|
-
throw invalid(`helo "${value}" is not a host name`);
|
|
539
|
-
return value;
|
|
540
|
-
}
|
|
541
|
-
if (byMx)
|
|
542
|
-
throw invalid("helo is required for delivery by MX: pass your server's public name, such as helo: 'mail.example.com'");
|
|
543
|
-
const name = machineName();
|
|
544
|
-
return isHelloName(name) ? name : "localhost";
|
|
545
|
-
}
|
|
546
|
-
function authOf(auth) {
|
|
547
|
-
if (auth === undefined)
|
|
548
|
-
return;
|
|
549
|
-
const { username, password, mechanism } = auth;
|
|
550
|
-
if (typeof username !== "string" || username === "")
|
|
551
|
-
throw invalid("auth.username must be a non-empty string");
|
|
552
|
-
if (typeof password !== "string" || password === "")
|
|
553
|
-
throw invalid("auth.password must be a non-empty string");
|
|
554
|
-
if (mechanism !== undefined && mechanism !== "PLAIN" && mechanism !== "LOGIN")
|
|
555
|
-
throw invalid(`auth.mechanism must be 'PLAIN' or 'LOGIN', not ${mechanism}`);
|
|
556
|
-
return auth;
|
|
557
|
-
}
|
|
558
|
-
function tlsOf(options, plaintextAuth) {
|
|
559
|
-
const secure = options.secure === true;
|
|
560
|
-
const tls = options.tls ?? (secure || options.auth && !plaintextAuth ? "required" : "opportunistic");
|
|
561
|
-
if (tls !== "opportunistic" && tls !== "required" && tls !== "none")
|
|
562
|
-
throw invalid(`tls must be 'opportunistic', 'required' or 'none', not ${tls}`);
|
|
563
|
-
if (tls === "none" && secure)
|
|
564
|
-
throw invalid("secure is TLS from the first byte: it cannot go with tls: 'none'");
|
|
565
|
-
if (tls === "none" && options.auth && !plaintextAuth)
|
|
566
|
-
throw invalid("auth with tls: 'none' would send the password in clear; pass allowPlaintextAuth: true for a local test server");
|
|
567
|
-
if (tls === "opportunistic" && options.auth && !plaintextAuth)
|
|
568
|
-
throw invalid("auth with tls: 'opportunistic' would send the password to a server whose certificate is not checked; leave tls out to check it, or pass allowPlaintextAuth: true for a local test server");
|
|
569
|
-
return tls;
|
|
570
|
-
}
|
|
571
|
-
function settingsOf(options) {
|
|
572
|
-
if (typeof options !== "object" || options === null)
|
|
573
|
-
throw invalid("options must be an object");
|
|
574
|
-
const from = address(options.from, true);
|
|
575
|
-
const list = typeof options.to === "string" ? [options.to] : options.to;
|
|
576
|
-
if (!Array.isArray(list) || list.length === 0)
|
|
577
|
-
throw invalid("to must name one recipient or more");
|
|
578
|
-
const to = list.map((recipient) => address(recipient, false));
|
|
579
|
-
const allowPlaintextAuth = options.allowPlaintextAuth === true;
|
|
580
|
-
const { size } = options;
|
|
581
|
-
if (size !== undefined && (!Number.isSafeInteger(size) || size < 0))
|
|
582
|
-
throw invalid(`size must be a number of bytes, not ${size}`);
|
|
583
|
-
const ca = options.ca === undefined ? undefined : [options.ca].flat();
|
|
584
|
-
const auth = authOf(options.auth);
|
|
585
|
-
return {
|
|
586
|
-
from,
|
|
587
|
-
to,
|
|
588
|
-
helo: heloOf(options.helo, byMx(options)),
|
|
589
|
-
tls: tlsOf(options, allowPlaintextAuth),
|
|
590
|
-
secure: options.secure === true,
|
|
591
|
-
...ca ? { ca } : {},
|
|
592
|
-
...auth ? { auth } : {},
|
|
593
|
-
allowPlaintextAuth,
|
|
594
|
-
...size === undefined ? {} : { size },
|
|
595
|
-
dsn: dsnOf(options, to, invalid),
|
|
596
|
-
smtputf8: options.smtputf8 === true || [from, ...to].some((text) => /[^ -~]/.test(text)),
|
|
597
|
-
normalizeLineEnds: options.normalizeLineEnds === true,
|
|
598
|
-
timeouts: timeoutsOf(options.timeouts),
|
|
599
|
-
deadline: seconds("deadline", options.deadline, 1800)
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
var byMx = (options) => !(("host" in options) && options.host !== undefined);
|
|
603
|
-
function portOf(port, fallback) {
|
|
604
|
-
if (port === undefined)
|
|
605
|
-
return fallback;
|
|
606
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
607
|
-
throw invalid(`port must be an integer from 1 to 65535, not ${port}`);
|
|
608
|
-
return port;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
411
|
// src/client/inbox.ts
|
|
612
412
|
var MAX_REPLY_BYTES = 1024 * 1024;
|
|
613
413
|
|
|
@@ -634,6 +434,9 @@ class Inbox {
|
|
|
634
434
|
}
|
|
635
435
|
return;
|
|
636
436
|
}
|
|
437
|
+
reset() {
|
|
438
|
+
this.#received = 0;
|
|
439
|
+
}
|
|
637
440
|
shift() {
|
|
638
441
|
return this.#replies.shift();
|
|
639
442
|
}
|
|
@@ -675,10 +478,26 @@ class Clock {
|
|
|
675
478
|
}
|
|
676
479
|
}
|
|
677
480
|
|
|
481
|
+
// src/client/socket-events.ts
|
|
482
|
+
function socketHandlers(live, hooks) {
|
|
483
|
+
const when = (run) => (...args) => {
|
|
484
|
+
if (live())
|
|
485
|
+
run(...args);
|
|
486
|
+
};
|
|
487
|
+
return {
|
|
488
|
+
data: when((_, chunk) => hooks.receive(chunk)),
|
|
489
|
+
drain: when(() => hooks.drain()),
|
|
490
|
+
close: when(() => hooks.lost()),
|
|
491
|
+
error: when(() => hooks.lost()),
|
|
492
|
+
handshake: when((_, ok, error) => hooks.handshake(ok, error))
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
var connectFailure = (endpoint, error) => error instanceof SmtpError2 ? error : new SmtpError2("CONNECTION_FAILED", `Could not connect to ${endpoint.host} (${endpoint.address}:${endpoint.port}): ${reason(error)}`);
|
|
496
|
+
|
|
678
497
|
// src/client/socket.ts
|
|
679
498
|
class ClientSocket {
|
|
680
499
|
host;
|
|
681
|
-
|
|
500
|
+
clock;
|
|
682
501
|
#socket;
|
|
683
502
|
#outgoing = new Outgoing(() => 0);
|
|
684
503
|
#tls = false;
|
|
@@ -687,12 +506,13 @@ class ClientSocket {
|
|
|
687
506
|
#closed = false;
|
|
688
507
|
#wake;
|
|
689
508
|
#handshaking;
|
|
690
|
-
#
|
|
509
|
+
#watchers = new Set;
|
|
691
510
|
secure = false;
|
|
511
|
+
replies = 0;
|
|
692
512
|
verified = false;
|
|
693
513
|
constructor(host, clock) {
|
|
694
514
|
this.host = host;
|
|
695
|
-
this
|
|
515
|
+
this.clock = clock;
|
|
696
516
|
}
|
|
697
517
|
static async open(endpoint, tls, seconds, clock) {
|
|
698
518
|
const client = new ClientSocket(endpoint.host, clock);
|
|
@@ -710,9 +530,7 @@ class ClientSocket {
|
|
|
710
530
|
client.#use(await client.#race(connecting, seconds, "the connection"));
|
|
711
531
|
} catch (error) {
|
|
712
532
|
connecting.then((socket) => socket.terminate(), () => {});
|
|
713
|
-
|
|
714
|
-
throw error;
|
|
715
|
-
throw new SmtpError2("CONNECTION_FAILED", `Could not connect to ${endpoint.host} (${address}:${port}): ${reason(error)}`);
|
|
533
|
+
throw connectFailure(endpoint, error);
|
|
716
534
|
}
|
|
717
535
|
if (handshake) {
|
|
718
536
|
await client.#race(handshake, seconds, "the TLS handshake").catch((e) => {
|
|
@@ -727,17 +545,12 @@ class ClientSocket {
|
|
|
727
545
|
this.#outgoing = new Outgoing((bytes) => socket.write(bytes));
|
|
728
546
|
}
|
|
729
547
|
#handlers(tls) {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
drain: live(() => this.#outgoing.drain()),
|
|
737
|
-
close: live(() => this.#lost()),
|
|
738
|
-
error: live(() => this.#lost()),
|
|
739
|
-
handshake: live((_, ok, error) => this.#handshaking?.(ok, error))
|
|
740
|
-
};
|
|
548
|
+
return socketHandlers(() => this.#tls === tls, {
|
|
549
|
+
receive: (chunk) => this.#receive(chunk),
|
|
550
|
+
drain: () => this.#outgoing.drain(),
|
|
551
|
+
lost: () => this.#lost(),
|
|
552
|
+
handshake: (ok, error) => this.#handshaking?.(ok, error)
|
|
553
|
+
});
|
|
741
554
|
}
|
|
742
555
|
#expectHandshake(tls) {
|
|
743
556
|
return new Promise((resolve, reject) => {
|
|
@@ -769,7 +582,8 @@ class ClientSocket {
|
|
|
769
582
|
}
|
|
770
583
|
#lost() {
|
|
771
584
|
this.#closed = true;
|
|
772
|
-
this.#
|
|
585
|
+
for (const watcher of this.#watchers)
|
|
586
|
+
watcher();
|
|
773
587
|
this.#outgoing.clear();
|
|
774
588
|
this.#handshaking?.(false, null);
|
|
775
589
|
this.#wakeUp();
|
|
@@ -782,16 +596,17 @@ class ClientSocket {
|
|
|
782
596
|
}
|
|
783
597
|
#race(promise, seconds, what, start = performance.now()) {
|
|
784
598
|
const ms = start + seconds * 1000 - performance.now();
|
|
785
|
-
const { host } = this;
|
|
786
|
-
|
|
787
|
-
return this.#clock.race(promise, ms, (passed) => this.#fail(timedOut(host, what, seconds, passed ? deadline : undefined)));
|
|
599
|
+
const { host, clock } = this;
|
|
600
|
+
return clock.race(promise, ms, (passed) => this.#fail(timedOut(host, what, seconds, passed ? clock.seconds : undefined)));
|
|
788
601
|
}
|
|
789
602
|
async reply(what, seconds) {
|
|
790
603
|
const start = performance.now();
|
|
791
604
|
for (;; ) {
|
|
792
605
|
const next = this.#inbox.shift();
|
|
793
|
-
if (next)
|
|
606
|
+
if (next) {
|
|
607
|
+
this.replies++;
|
|
794
608
|
return next;
|
|
609
|
+
}
|
|
795
610
|
if (this.#failure)
|
|
796
611
|
throw this.#failure;
|
|
797
612
|
if (this.#closed)
|
|
@@ -802,16 +617,28 @@ class ClientSocket {
|
|
|
802
617
|
}
|
|
803
618
|
}
|
|
804
619
|
within(promise, seconds, what) {
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
620
|
+
const watcher = Promise.withResolvers();
|
|
621
|
+
if (this.#closed)
|
|
622
|
+
watcher.resolve();
|
|
623
|
+
else
|
|
624
|
+
this.#watchers.add(watcher.resolve);
|
|
625
|
+
const gone = watcher.promise;
|
|
626
|
+
let over = false;
|
|
627
|
+
const hung = gone.then(() => {
|
|
628
|
+
if (!over)
|
|
808
629
|
throw this.#hungUp(lost(this.host, what));
|
|
809
630
|
});
|
|
810
|
-
const race = Promise.race([promise,
|
|
631
|
+
const race = Promise.race([promise, hung]);
|
|
811
632
|
race.catch(() => {
|
|
812
633
|
return;
|
|
813
634
|
});
|
|
814
|
-
|
|
635
|
+
hung.catch(() => {
|
|
636
|
+
return;
|
|
637
|
+
});
|
|
638
|
+
return this.#race(race, seconds, what).finally(() => {
|
|
639
|
+
over = true;
|
|
640
|
+
this.#watchers.delete(watcher.resolve);
|
|
641
|
+
});
|
|
815
642
|
}
|
|
816
643
|
write(data) {
|
|
817
644
|
if (this.#failure || this.#closed)
|
|
@@ -845,6 +672,18 @@ class ClientSocket {
|
|
|
845
672
|
this.#use(encrypted);
|
|
846
673
|
await this.#race(handshake, seconds, "the TLS handshake");
|
|
847
674
|
}
|
|
675
|
+
get quiet() {
|
|
676
|
+
return !this.#failure && !this.#closed && !this.#inbox.holding;
|
|
677
|
+
}
|
|
678
|
+
ref(keep) {
|
|
679
|
+
if (keep)
|
|
680
|
+
this.#socket?.ref();
|
|
681
|
+
else
|
|
682
|
+
this.#socket?.unref();
|
|
683
|
+
}
|
|
684
|
+
newMessage() {
|
|
685
|
+
this.#inbox.reset();
|
|
686
|
+
}
|
|
848
687
|
close(now = false) {
|
|
849
688
|
this.#closed = true;
|
|
850
689
|
if (now)
|
|
@@ -854,14 +693,253 @@ class ClientSocket {
|
|
|
854
693
|
}
|
|
855
694
|
}
|
|
856
695
|
|
|
857
|
-
// src/client/
|
|
858
|
-
var
|
|
859
|
-
function
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
696
|
+
// src/client/connect.ts
|
|
697
|
+
var early = new WeakSet;
|
|
698
|
+
async function connect(endpoint, settings, clock) {
|
|
699
|
+
const tls = {
|
|
700
|
+
servername: endpoint.host,
|
|
701
|
+
verify: settings.tls === "required",
|
|
702
|
+
...settings.ca ? { ca: settings.ca } : {}
|
|
703
|
+
};
|
|
704
|
+
let socket;
|
|
705
|
+
try {
|
|
706
|
+
socket = await ClientSocket.open(endpoint, settings.secure ? tls : undefined, settings.timeouts.connect, clock);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
early.add(error);
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
const ready = await open(socket, settings, tls);
|
|
713
|
+
return { socket, ready };
|
|
714
|
+
} catch (error) {
|
|
715
|
+
early.add(error);
|
|
716
|
+
socket.write(`QUIT\r
|
|
717
|
+
`);
|
|
718
|
+
socket.close();
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function resultOf(delivered, { socket, ready }, endpoint) {
|
|
723
|
+
return {
|
|
724
|
+
...delivered,
|
|
725
|
+
host: endpoint.host,
|
|
726
|
+
port: endpoint.port,
|
|
727
|
+
tls: socket.secure ? { verified: socket.verified } : false,
|
|
728
|
+
authenticated: ready.authenticated
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/client/settings.ts
|
|
733
|
+
import { hostname as machineName } from "node:os";
|
|
734
|
+
|
|
735
|
+
// src/client/dsn.ts
|
|
736
|
+
var EVENTS = ["SUCCESS", "FAILURE", "DELAY"];
|
|
737
|
+
function dsnOf(given, to, fail) {
|
|
738
|
+
const { ret, envid, recipients } = given;
|
|
739
|
+
if (ret !== undefined && ret !== "FULL" && ret !== "HDRS")
|
|
740
|
+
throw fail(`ret must be 'FULL' or 'HDRS', not ${String(ret)}`);
|
|
741
|
+
if (envid !== undefined && (typeof envid !== "string" || !/^[ -~]{1,100}$/.test(envid) || encodeXtext(envid).length > 100))
|
|
742
|
+
throw fail("envid must be 1 to 100 printable ASCII characters, at most 100 once encoded as xtext");
|
|
743
|
+
const byAddress = new Map;
|
|
744
|
+
if (recipients !== undefined) {
|
|
745
|
+
if (!Array.isArray(recipients))
|
|
746
|
+
throw fail("recipients must be an array of { address, notify, orcpt }");
|
|
747
|
+
for (const entry of recipients) {
|
|
748
|
+
const address = entry?.address;
|
|
749
|
+
if (typeof address !== "string" || !to.includes(address))
|
|
750
|
+
throw fail(`recipients names ${JSON.stringify(address)}, which is not in to`);
|
|
751
|
+
if (byAddress.has(address))
|
|
752
|
+
throw fail(`recipients names ${address} twice`);
|
|
753
|
+
checkRecipient(entry, fail);
|
|
754
|
+
byAddress.set(address, entry);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
...ret ? { ret } : {},
|
|
759
|
+
...envid === undefined ? {} : { envid },
|
|
760
|
+
recipients: byAddress
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function checkRecipient({ address, notify, orcpt }, fail) {
|
|
764
|
+
if (notify !== undefined && notify !== "NEVER") {
|
|
765
|
+
const events = Array.isArray(notify) ? notify : [];
|
|
766
|
+
if (events.length === 0 || events.some((e, i) => !EVENTS.includes(e) || events.indexOf(e) !== i))
|
|
767
|
+
throw fail(`notify of ${address} must be 'NEVER' or a list of 'SUCCESS', 'FAILURE', 'DELAY', each once`);
|
|
768
|
+
}
|
|
769
|
+
if (orcpt !== undefined) {
|
|
770
|
+
if (typeof orcpt !== "object" || orcpt === null || typeof orcpt.type !== "string" || !/^[A-Za-z0-9-]+$/.test(orcpt.type))
|
|
771
|
+
throw fail(`orcpt of ${address} must be { type, address }, the type a token`);
|
|
772
|
+
if (typeof orcpt.address !== "string" || orcpt.address === "")
|
|
773
|
+
throw fail(`orcpt.address of ${address} must be a non-empty string`);
|
|
774
|
+
const encoded = orcpt.type.toLowerCase() === "utf-8" ? utf8OrcptLength(orcpt.address) : encodeXtext(orcpt.address).length;
|
|
775
|
+
if (orcpt.type.length + 1 + encoded > 500)
|
|
776
|
+
throw fail(`orcpt of ${address} is over 500 characters once encoded`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function mailDsn(dsn, offered) {
|
|
780
|
+
if (!offered)
|
|
781
|
+
return "";
|
|
782
|
+
let out = "";
|
|
783
|
+
if (dsn.ret)
|
|
784
|
+
out += ` RET=${dsn.ret}`;
|
|
785
|
+
if (dsn.envid !== undefined)
|
|
786
|
+
out += ` ENVID=${encodeXtext(dsn.envid)}`;
|
|
787
|
+
return out;
|
|
788
|
+
}
|
|
789
|
+
function orcptParameter({ type, address }, smtputf8) {
|
|
790
|
+
const kind = type.toLowerCase();
|
|
791
|
+
if (kind === "rfc822")
|
|
792
|
+
return /^[\x20-\x7e]+$/.test(address) ? ` ORCPT=${type};${encodeXtext(address)}` : "";
|
|
793
|
+
if (kind !== "utf-8") {
|
|
794
|
+
const value = `${type};${encodeXtext(address)}`;
|
|
795
|
+
return address.isWellFormed() ? ` ORCPT=${value}` : "";
|
|
796
|
+
}
|
|
797
|
+
const encoded = encodeUtf8Orcpt(address, smtputf8);
|
|
798
|
+
if (encoded === undefined)
|
|
799
|
+
return "";
|
|
800
|
+
const value = `${type};${encoded}`;
|
|
801
|
+
return new TextEncoder().encode(value).length > 500 ? "" : ` ORCPT=${value}`;
|
|
802
|
+
}
|
|
803
|
+
function rcptDsn(dsn, address, offered, smtputf8 = false) {
|
|
804
|
+
const entry = dsn.recipients.get(address);
|
|
805
|
+
if (!offered || !entry)
|
|
806
|
+
return "";
|
|
807
|
+
let out = "";
|
|
808
|
+
const { notify, orcpt } = entry;
|
|
809
|
+
if (notify !== undefined)
|
|
810
|
+
out += ` NOTIFY=${notify === "NEVER" ? "NEVER" : notify.join(",")}`;
|
|
811
|
+
if (orcpt)
|
|
812
|
+
out += orcptParameter(orcpt, smtputf8);
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/client/settings.ts
|
|
817
|
+
var MAX_SECONDS = Math.floor(2147483647 / 1000);
|
|
818
|
+
var TIMEOUTS = {
|
|
819
|
+
connect: 30,
|
|
820
|
+
greeting: 300,
|
|
821
|
+
command: 300,
|
|
822
|
+
mail: 300,
|
|
823
|
+
rcpt: 300,
|
|
824
|
+
dataStart: 120,
|
|
825
|
+
dataBlock: 180,
|
|
826
|
+
dataEnd: 600
|
|
827
|
+
};
|
|
828
|
+
var invalid = (message) => new SmtpError2("INVALID_OPTION", `sendMail(): ${message}`);
|
|
829
|
+
function seconds(name, value, fallback) {
|
|
830
|
+
if (value === undefined)
|
|
831
|
+
return fallback;
|
|
832
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_SECONDS) {
|
|
833
|
+
throw invalid(`${name} must be a number of seconds above 0 and at most ${MAX_SECONDS}, not ${String(value)}`);
|
|
834
|
+
}
|
|
835
|
+
return value;
|
|
836
|
+
}
|
|
837
|
+
function timeoutsOf(given = {}) {
|
|
838
|
+
const out = {};
|
|
839
|
+
for (const [name, fallback] of Object.entries(TIMEOUTS)) {
|
|
840
|
+
out[name] = seconds(`timeouts.${name}`, given[name], fallback);
|
|
841
|
+
}
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
function address(value, allowNull) {
|
|
845
|
+
if (allowNull && value === "")
|
|
846
|
+
return "";
|
|
847
|
+
if (isMailbox(value))
|
|
848
|
+
return value;
|
|
849
|
+
const text = typeof value === "string" ? value : "";
|
|
850
|
+
const routed = typeof value === "string" && parsePath2(`<${text}>`, false);
|
|
851
|
+
if (routed) {
|
|
852
|
+
throw invalid(`${JSON.stringify(value)} holds a source route (@host:), which RFC 5321 says a client should not send: pass ${JSON.stringify(`${routed.local}@${text.slice(text.lastIndexOf("@") + 1)}`)} alone`);
|
|
853
|
+
}
|
|
854
|
+
throw invalid(`${JSON.stringify(value)} is not an address (local@domain${allowNull ? ", or '' for a bounce" : ""})`);
|
|
855
|
+
}
|
|
856
|
+
function heloOf(value, byMx) {
|
|
857
|
+
if (value !== undefined) {
|
|
858
|
+
if (!isHelloName(value))
|
|
859
|
+
throw invalid(`helo "${value}" is not a host name`);
|
|
860
|
+
return value;
|
|
861
|
+
}
|
|
862
|
+
if (byMx)
|
|
863
|
+
throw invalid("helo is required for delivery by MX: pass your server's public name, such as helo: 'mail.example.com'");
|
|
864
|
+
const name = machineName();
|
|
865
|
+
return isHelloName(name) ? name : "localhost";
|
|
866
|
+
}
|
|
867
|
+
function authOf(auth) {
|
|
868
|
+
if (auth === undefined)
|
|
869
|
+
return;
|
|
870
|
+
const { username, password, mechanism } = auth;
|
|
871
|
+
if (typeof username !== "string" || username === "")
|
|
872
|
+
throw invalid("auth.username must be a non-empty string");
|
|
873
|
+
if (typeof password !== "string" || password === "")
|
|
874
|
+
throw invalid("auth.password must be a non-empty string");
|
|
875
|
+
if (mechanism !== undefined && mechanism !== "PLAIN" && mechanism !== "LOGIN")
|
|
876
|
+
throw invalid(`auth.mechanism must be 'PLAIN' or 'LOGIN', not ${mechanism}`);
|
|
877
|
+
return auth;
|
|
878
|
+
}
|
|
879
|
+
function tlsOf(options, plaintextAuth) {
|
|
880
|
+
const secure = options.secure === true;
|
|
881
|
+
const tls = options.tls ?? (secure || options.auth && !plaintextAuth ? "required" : "opportunistic");
|
|
882
|
+
if (tls !== "opportunistic" && tls !== "required" && tls !== "none")
|
|
883
|
+
throw invalid(`tls must be 'opportunistic', 'required' or 'none', not ${tls}`);
|
|
884
|
+
if (tls === "none" && secure)
|
|
885
|
+
throw invalid("secure is TLS from the first byte: it cannot go with tls: 'none'");
|
|
886
|
+
if (tls === "none" && options.auth && !plaintextAuth)
|
|
887
|
+
throw invalid("auth with tls: 'none' would send the password in clear; pass allowPlaintextAuth: true for a local test server");
|
|
888
|
+
if (tls === "opportunistic" && options.auth && !plaintextAuth)
|
|
889
|
+
throw invalid("auth with tls: 'opportunistic' would send the password to a server whose certificate is not checked; leave tls out to check it, or pass allowPlaintextAuth: true for a local test server");
|
|
890
|
+
return tls;
|
|
891
|
+
}
|
|
892
|
+
function settingsOf(options) {
|
|
893
|
+
if (typeof options !== "object" || options === null)
|
|
894
|
+
throw invalid("options must be an object");
|
|
895
|
+
const from = address(options.from, true);
|
|
896
|
+
const list = typeof options.to === "string" ? [options.to] : options.to;
|
|
897
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
898
|
+
throw invalid("to must name one recipient or more");
|
|
899
|
+
const to = list.map((recipient) => address(recipient, false));
|
|
900
|
+
const allowPlaintextAuth = options.allowPlaintextAuth === true;
|
|
901
|
+
const { size } = options;
|
|
902
|
+
if (size !== undefined && (!Number.isSafeInteger(size) || size < 0))
|
|
903
|
+
throw invalid(`size must be a number of bytes, not ${size}`);
|
|
904
|
+
const ca = options.ca === undefined ? undefined : [options.ca].flat();
|
|
905
|
+
const auth = authOf(options.auth);
|
|
906
|
+
return {
|
|
907
|
+
from,
|
|
908
|
+
to,
|
|
909
|
+
helo: heloOf(options.helo, byMx(options)),
|
|
910
|
+
tls: tlsOf(options, allowPlaintextAuth),
|
|
911
|
+
secure: options.secure === true,
|
|
912
|
+
...ca ? { ca } : {},
|
|
913
|
+
...auth ? { auth } : {},
|
|
914
|
+
allowPlaintextAuth,
|
|
915
|
+
...size === undefined ? {} : { size },
|
|
916
|
+
dsn: dsnOf(options, to, invalid),
|
|
917
|
+
smtputf8: options.smtputf8 === true || [from, ...to].some((text) => /[^ -~]/.test(text)),
|
|
918
|
+
normalizeLineEnds: options.normalizeLineEnds === true,
|
|
919
|
+
timeouts: timeoutsOf(options.timeouts),
|
|
920
|
+
deadline: seconds("deadline", options.deadline, 1800)
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
var byMx = (options) => !(("host" in options) && options.host !== undefined);
|
|
924
|
+
function portOf(port, fallback) {
|
|
925
|
+
if (port === undefined)
|
|
926
|
+
return fallback;
|
|
927
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
928
|
+
throw invalid(`port must be an integer from 1 to 65535, not ${port}`);
|
|
929
|
+
return port;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/client/transaction.ts
|
|
933
|
+
var stepped = new WeakSet;
|
|
934
|
+
var broken = new WeakSet;
|
|
935
|
+
var inStep = (error) => typeof error === "object" && error !== null && stepped.has(error);
|
|
936
|
+
var missing = (host, extension, why) => new SmtpError2("EXTENSION_MISSING", `${host} does not offer ${extension}, which ${why}`);
|
|
937
|
+
function mailParameters(host, settings, content, extensions) {
|
|
938
|
+
let parameters = "";
|
|
939
|
+
const size = extensions.get("SIZE");
|
|
940
|
+
if (size !== undefined && content.size !== undefined) {
|
|
941
|
+
const max = Number(size);
|
|
942
|
+
if (Number.isSafeInteger(max) && max > 0 && content.size > max) {
|
|
865
943
|
throw new SmtpError2("MESSAGE_TOO_BIG", `The message (${content.size} bytes) is larger than the ${max} bytes ${host} takes (SIZE)`, { temporary: false });
|
|
866
944
|
}
|
|
867
945
|
parameters += ` SIZE=${content.size}`;
|
|
@@ -883,15 +961,34 @@ async function envelope(socket, settings, content, ready) {
|
|
|
883
961
|
const { host } = socket;
|
|
884
962
|
const { timeouts } = settings;
|
|
885
963
|
const dsn = ready.extensions.has("DSN");
|
|
886
|
-
|
|
964
|
+
let parameters;
|
|
965
|
+
try {
|
|
966
|
+
parameters = mailParameters(host, settings, content, ready.extensions);
|
|
967
|
+
} catch (error) {
|
|
968
|
+
stepped.add(error);
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
const mail = `MAIL FROM:<${settings.from}>${parameters}${mailDsn(settings.dsn, dsn)}\r
|
|
887
972
|
`;
|
|
888
973
|
const rcpts = settings.to.map((to) => `RCPT TO:<${to}>${rcptDsn(settings.dsn, to, dsn, settings.smtputf8)}\r
|
|
889
974
|
`);
|
|
890
975
|
const pipelining = ready.extensions.has("PIPELINING");
|
|
891
976
|
socket.write(pipelining ? mail + rcpts.join("") : mail);
|
|
892
977
|
const from = await socket.reply("the reply to MAIL FROM", timeouts.mail);
|
|
893
|
-
if (from.code !== 250)
|
|
894
|
-
|
|
978
|
+
if (from.code !== 250) {
|
|
979
|
+
const refusal = refused(host, "the sender", from);
|
|
980
|
+
if (pipelining) {
|
|
981
|
+
for (const _ of rcpts) {
|
|
982
|
+
try {
|
|
983
|
+
await socket.reply("the reply to RCPT TO", timeouts.rcpt);
|
|
984
|
+
} catch {
|
|
985
|
+
broken.add(refusal);
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
throw refusal;
|
|
991
|
+
}
|
|
895
992
|
const accepted = [];
|
|
896
993
|
const rejected = [];
|
|
897
994
|
for (const [i, recipient] of settings.to.entries()) {
|
|
@@ -954,63 +1051,49 @@ async function data(socket, settings, content, ready, taken) {
|
|
|
954
1051
|
return end;
|
|
955
1052
|
}
|
|
956
1053
|
async function transact(socket, settings, content, ready) {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1054
|
+
try {
|
|
1055
|
+
const { accepted, rejected } = await envelope(socket, settings, content, ready);
|
|
1056
|
+
const reply = await data(socket, settings, content, ready, {
|
|
1057
|
+
accepted,
|
|
1058
|
+
rejected
|
|
1059
|
+
});
|
|
1060
|
+
const delivered = {
|
|
1061
|
+
accepted,
|
|
1062
|
+
rejected,
|
|
1063
|
+
reply,
|
|
1064
|
+
dsnOffered: ready.extensions.has("DSN")
|
|
1065
|
+
};
|
|
1066
|
+
return { delivered };
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
const code = error instanceof SmtpError2 ? error.reply?.code : undefined;
|
|
1069
|
+
if (code !== undefined && code >= 400 && code < 600 && code !== 421 && !broken.has(error))
|
|
1070
|
+
stepped.add(error);
|
|
1071
|
+
throw error;
|
|
1072
|
+
}
|
|
968
1073
|
}
|
|
969
1074
|
|
|
970
1075
|
// src/client/send.ts
|
|
971
1076
|
var MAX_ADDRESSES = 10;
|
|
972
1077
|
var QUIT_WAIT = 5;
|
|
973
|
-
var
|
|
974
|
-
|
|
975
|
-
const tls = {
|
|
976
|
-
servername: endpoint.host,
|
|
977
|
-
verify: settings.tls === "required",
|
|
978
|
-
...settings.ca ? { ca: settings.ca } : {}
|
|
979
|
-
};
|
|
980
|
-
let socket;
|
|
981
|
-
try {
|
|
982
|
-
socket = await ClientSocket.open(endpoint, settings.secure ? tls : undefined, settings.timeouts.connect, clock);
|
|
983
|
-
} catch (error) {
|
|
984
|
-
early.add(error);
|
|
985
|
-
throw error;
|
|
986
|
-
}
|
|
1078
|
+
var attempt = async (endpoint, settings, content, clock) => {
|
|
1079
|
+
const { socket, ready } = await connect(endpoint, settings, clock);
|
|
987
1080
|
let quit = false;
|
|
988
1081
|
try {
|
|
989
|
-
const
|
|
990
|
-
early.add(error);
|
|
991
|
-
throw error;
|
|
992
|
-
});
|
|
993
|
-
const delivered = await transact(socket, settings, content, ready);
|
|
1082
|
+
const { delivered } = await transact(socket, settings, content, ready);
|
|
994
1083
|
socket.write(`QUIT\r
|
|
995
1084
|
`);
|
|
996
1085
|
quit = true;
|
|
997
1086
|
await socket.reply("the reply to QUIT", Math.min(QUIT_WAIT, settings.timeouts.command)).catch(() => {});
|
|
998
|
-
return {
|
|
999
|
-
...delivered,
|
|
1000
|
-
host: endpoint.host,
|
|
1001
|
-
port: endpoint.port,
|
|
1002
|
-
tls: socket.secure ? { verified: socket.verified } : false,
|
|
1003
|
-
authenticated: ready.authenticated
|
|
1004
|
-
};
|
|
1087
|
+
return resultOf(delivered, { socket, ready }, endpoint);
|
|
1005
1088
|
} finally {
|
|
1006
1089
|
if (!quit)
|
|
1007
1090
|
socket.write(`QUIT\r
|
|
1008
1091
|
`);
|
|
1009
1092
|
socket.close();
|
|
1010
1093
|
}
|
|
1011
|
-
}
|
|
1094
|
+
};
|
|
1012
1095
|
var withinDeadline = (lookup, clock, name, what) => clock.race(lookup, Number.POSITIVE_INFINITY, () => timedOut(name, what, clock.seconds, clock.seconds));
|
|
1013
|
-
async function viaMx(options, settings, content, clock) {
|
|
1096
|
+
async function viaMx(try_, options, settings, content, clock) {
|
|
1014
1097
|
const { domain, resolver } = options;
|
|
1015
1098
|
if (typeof domain !== "string" || domain === "")
|
|
1016
1099
|
throw invalid("domain must be a domain name");
|
|
@@ -1035,7 +1118,7 @@ async function viaMx(options, settings, content, clock) {
|
|
|
1035
1118
|
if (tried++ >= MAX_ADDRESSES)
|
|
1036
1119
|
break hosts;
|
|
1037
1120
|
try {
|
|
1038
|
-
return await
|
|
1121
|
+
return await try_({ host: mx.host, address, port }, settings, content, clock);
|
|
1039
1122
|
} catch (error) {
|
|
1040
1123
|
const next = error instanceof SmtpError2 && error.temporary && early.has(error);
|
|
1041
1124
|
if (!next)
|
|
@@ -1046,25 +1129,237 @@ async function viaMx(options, settings, content, clock) {
|
|
|
1046
1129
|
}
|
|
1047
1130
|
throw last ?? noAddress(domain, dnsError);
|
|
1048
1131
|
}
|
|
1049
|
-
|
|
1050
|
-
const settings = settingsOf(options);
|
|
1051
|
-
const content = contentOf(message, settings.normalizeLineEnds, settings.size);
|
|
1052
|
-
const clock = new Clock(settings.deadline);
|
|
1132
|
+
function deliver(try_, options, settings, content, clock) {
|
|
1053
1133
|
if (!byMx(options)) {
|
|
1054
1134
|
const { host } = options;
|
|
1055
1135
|
if (typeof host !== "string" || !/^[^\s/]+$/.test(host))
|
|
1056
1136
|
throw invalid(`host must be a host name or an address, not ${host}`);
|
|
1057
1137
|
const port = portOf(options.port, settings.secure ? 465 : 25);
|
|
1058
|
-
return
|
|
1138
|
+
return try_({ host, address: host, port }, settings, content, clock);
|
|
1139
|
+
}
|
|
1140
|
+
return viaMx(try_, options, settings, content, clock);
|
|
1141
|
+
}
|
|
1142
|
+
async function sendMail(message, options) {
|
|
1143
|
+
const settings = settingsOf(options);
|
|
1144
|
+
const content = contentOf(message, settings.normalizeLineEnds, settings.size);
|
|
1145
|
+
return deliver(attempt, options, settings, content, new Clock(settings.deadline));
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/client/pool/options.ts
|
|
1149
|
+
var invalid2 = (message) => new SmtpError2("INVALID_OPTION", `createSmtpPool(): ${message}`);
|
|
1150
|
+
function count(name, value, fallback) {
|
|
1151
|
+
if (value === undefined)
|
|
1152
|
+
return fallback;
|
|
1153
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
1154
|
+
throw invalid2(`${name} must be an integer of at least 1, not ${String(value)}`);
|
|
1155
|
+
return value;
|
|
1156
|
+
}
|
|
1157
|
+
function seconds2(name, value, fallback) {
|
|
1158
|
+
if (value === undefined)
|
|
1159
|
+
return fallback;
|
|
1160
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_SECONDS)
|
|
1161
|
+
throw invalid2(`${name} must be a number of seconds above 0 and at most ${MAX_SECONDS}, not ${String(value)}`);
|
|
1162
|
+
return value;
|
|
1163
|
+
}
|
|
1164
|
+
function limitsOf(options = {}) {
|
|
1165
|
+
if (typeof options !== "object" || options === null)
|
|
1166
|
+
throw invalid2("options must be an object");
|
|
1167
|
+
return {
|
|
1168
|
+
maxMessages: count("maxMessages", options.maxMessages, 100),
|
|
1169
|
+
idleSeconds: seconds2("idleSeconds", options.idleSeconds, 5),
|
|
1170
|
+
maxAgeSeconds: seconds2("maxAgeSeconds", options.maxAgeSeconds, 300),
|
|
1171
|
+
maxIdlePerHost: count("maxIdlePerHost", options.maxIdlePerHost, 4)
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
var sameList = (a, b) => a === b || a !== undefined && b !== undefined && a.length === b.length && a.every((item, i) => item === b[i]);
|
|
1175
|
+
var sameAuth = (a, b) => a === b || a !== undefined && b !== undefined && a.username === b.username && a.password === b.password && a.mechanism === b.mechanism;
|
|
1176
|
+
function sameIdentity(a, b) {
|
|
1177
|
+
const x = a.connection;
|
|
1178
|
+
const y = b.connection;
|
|
1179
|
+
return a.host === b.host && a.address === b.address && a.port === b.port && x.helo === y.helo && x.tls === y.tls && x.secure === y.secure && x.allowPlaintextAuth === y.allowPlaintextAuth && sameList(x.ca, y.ca) && sameAuth(x.auth, y.auth);
|
|
1180
|
+
}
|
|
1181
|
+
var identityOf = (endpoint, settings) => ({
|
|
1182
|
+
host: endpoint.host,
|
|
1183
|
+
address: endpoint.address,
|
|
1184
|
+
port: endpoint.port,
|
|
1185
|
+
connection: {
|
|
1186
|
+
helo: settings.helo,
|
|
1187
|
+
tls: settings.tls,
|
|
1188
|
+
secure: settings.secure,
|
|
1189
|
+
allowPlaintextAuth: settings.allowPlaintextAuth,
|
|
1190
|
+
...settings.ca ? { ca: [...settings.ca] } : {},
|
|
1191
|
+
...settings.auth ? { auth: { ...settings.auth } } : {}
|
|
1059
1192
|
}
|
|
1060
|
-
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// src/client/pool/reset.ts
|
|
1196
|
+
var RESET_WAIT = 5;
|
|
1197
|
+
async function reset(entry, clock, seconds) {
|
|
1198
|
+
const { socket } = entry;
|
|
1199
|
+
if (!socket.quiet)
|
|
1200
|
+
return false;
|
|
1201
|
+
socket.clock = clock;
|
|
1202
|
+
socket.write(`RSET\r
|
|
1203
|
+
`);
|
|
1204
|
+
try {
|
|
1205
|
+
return (await socket.reply("the reply to RSET", seconds)).code === 250;
|
|
1206
|
+
} catch {
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
var stale = (entry, before, error) => error instanceof SmtpError2 && error.code === "CONNECTION_LOST" && error.reply === undefined && !error.afterData && entry.socket.replies === before;
|
|
1211
|
+
|
|
1212
|
+
// src/client/pool/state.ts
|
|
1213
|
+
var newState = (limits) => ({
|
|
1214
|
+
limits,
|
|
1215
|
+
idle: [],
|
|
1216
|
+
busy: new Set,
|
|
1217
|
+
resetting: new Set,
|
|
1218
|
+
closed: false
|
|
1219
|
+
});
|
|
1220
|
+
function hangUp(entry, polite = true) {
|
|
1221
|
+
clearTimeout(entry.timer);
|
|
1222
|
+
if (polite)
|
|
1223
|
+
entry.socket.write(`QUIT\r
|
|
1224
|
+
`);
|
|
1225
|
+
entry.socket.close();
|
|
1226
|
+
}
|
|
1227
|
+
var aged = (state, entry) => performance.now() - entry.born >= state.limits.maxAgeSeconds * 1000;
|
|
1228
|
+
|
|
1229
|
+
// src/client/pool/idle.ts
|
|
1230
|
+
function forget(state, entry) {
|
|
1231
|
+
const at = state.idle.indexOf(entry);
|
|
1232
|
+
if (at >= 0)
|
|
1233
|
+
state.idle.splice(at, 1);
|
|
1234
|
+
clearTimeout(entry.timer);
|
|
1235
|
+
}
|
|
1236
|
+
function release(state, entry) {
|
|
1237
|
+
const { limits, idle } = state;
|
|
1238
|
+
const spent = state.closed || entry.messages >= limits.maxMessages || aged(state, entry) || !entry.socket.quiet;
|
|
1239
|
+
for (const other of idle.filter((e) => !e.socket.quiet)) {
|
|
1240
|
+
forget(state, other);
|
|
1241
|
+
hangUp(other, false);
|
|
1242
|
+
}
|
|
1243
|
+
const same = idle.filter((e) => e.identity.host === entry.identity.host);
|
|
1244
|
+
if (spent || same.length >= limits.maxIdlePerHost) {
|
|
1245
|
+
hangUp(entry);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
entry.timer = setTimeout(() => {
|
|
1249
|
+
forget(state, entry);
|
|
1250
|
+
hangUp(entry);
|
|
1251
|
+
}, limits.idleSeconds * 1000);
|
|
1252
|
+
entry.timer.unref();
|
|
1253
|
+
entry.socket.ref(false);
|
|
1254
|
+
idle.push(entry);
|
|
1255
|
+
}
|
|
1256
|
+
async function borrow(state, identity, seconds, clock) {
|
|
1257
|
+
for (;; ) {
|
|
1258
|
+
const entry = state.idle.find((e) => sameIdentity(e.identity, identity));
|
|
1259
|
+
if (!entry)
|
|
1260
|
+
return;
|
|
1261
|
+
forget(state, entry);
|
|
1262
|
+
entry.socket.ref(true);
|
|
1263
|
+
if (aged(state, entry)) {
|
|
1264
|
+
hangUp(entry, true);
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (await reset(entry, clock, seconds))
|
|
1268
|
+
return entry;
|
|
1269
|
+
hangUp(entry, false);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// src/client/pool/send.ts
|
|
1274
|
+
class Stale extends Error {
|
|
1275
|
+
}
|
|
1276
|
+
function recover(state, entry) {
|
|
1277
|
+
const job = reset(entry, new Clock(RESET_WAIT), RESET_WAIT).then((ok) => ok ? release(state, entry) : hangUp(entry, false)).catch(() => hangUp(entry, false));
|
|
1278
|
+
state.resetting.add(job);
|
|
1279
|
+
job.finally(() => state.resetting.delete(job));
|
|
1280
|
+
}
|
|
1281
|
+
async function send(state, entry, settings, content, clock, reused) {
|
|
1282
|
+
const { socket, ready } = entry;
|
|
1283
|
+
socket.clock = clock;
|
|
1284
|
+
socket.newMessage();
|
|
1285
|
+
const before = socket.replies;
|
|
1286
|
+
let result;
|
|
1287
|
+
try {
|
|
1288
|
+
const { delivered } = await transact(socket, settings, content, ready);
|
|
1289
|
+
result = {
|
|
1290
|
+
...resultOf(delivered, entry, entry.identity),
|
|
1291
|
+
reused
|
|
1292
|
+
};
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
if (inStep(error)) {
|
|
1295
|
+
entry.messages++;
|
|
1296
|
+
recover(state, entry);
|
|
1297
|
+
} else
|
|
1298
|
+
hangUp(entry);
|
|
1299
|
+
if (reused && stale(entry, before, error))
|
|
1300
|
+
throw new Stale;
|
|
1301
|
+
throw error;
|
|
1302
|
+
}
|
|
1303
|
+
entry.messages++;
|
|
1304
|
+
release(state, entry);
|
|
1305
|
+
return result;
|
|
1306
|
+
}
|
|
1307
|
+
async function attempt2(state, endpoint, settings, content, clock) {
|
|
1308
|
+
const identity = identityOf(endpoint, settings);
|
|
1309
|
+
const wait = Math.min(RESET_WAIT, settings.timeouts.command);
|
|
1310
|
+
const entry = await borrow(state, identity, wait, clock);
|
|
1311
|
+
if (entry) {
|
|
1312
|
+
try {
|
|
1313
|
+
return await send(state, entry, settings, content, clock, true);
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
if (!(error instanceof Stale))
|
|
1316
|
+
throw error;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
const { socket, ready } = await connect(endpoint, settings, clock);
|
|
1320
|
+
const fresh = {
|
|
1321
|
+
socket,
|
|
1322
|
+
ready,
|
|
1323
|
+
identity,
|
|
1324
|
+
born: performance.now(),
|
|
1325
|
+
messages: 0
|
|
1326
|
+
};
|
|
1327
|
+
return send(state, fresh, settings, content, clock, false);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/client/pool/index.ts
|
|
1331
|
+
function createSmtpPool(options) {
|
|
1332
|
+
const state = newState(limitsOf(options));
|
|
1333
|
+
return {
|
|
1334
|
+
sendMail(message, sendOptions) {
|
|
1335
|
+
const job = (async () => {
|
|
1336
|
+
if (state.closed)
|
|
1337
|
+
throw new SmtpError2("STOPPED", "The SMTP pool is closed");
|
|
1338
|
+
const settings = settingsOf(sendOptions);
|
|
1339
|
+
const content = contentOf(message, settings.normalizeLineEnds, settings.size);
|
|
1340
|
+
return deliver((endpoint, s, c, clock) => attempt2(state, endpoint, s, c, clock), sendOptions, settings, content, new Clock(settings.deadline));
|
|
1341
|
+
})();
|
|
1342
|
+
state.busy.add(job);
|
|
1343
|
+
const done = () => state.busy.delete(job);
|
|
1344
|
+
job.then(done, done);
|
|
1345
|
+
return job;
|
|
1346
|
+
},
|
|
1347
|
+
async close() {
|
|
1348
|
+
state.closed = true;
|
|
1349
|
+
for (const entry of state.idle.splice(0))
|
|
1350
|
+
hangUp(entry);
|
|
1351
|
+
await Promise.allSettled([...state.busy]);
|
|
1352
|
+
await Promise.allSettled([...state.resetting]);
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1061
1355
|
}
|
|
1062
1356
|
export {
|
|
1063
1357
|
SmtpError2 as SmtpError,
|
|
1358
|
+
createSmtpPool,
|
|
1064
1359
|
isMailbox,
|
|
1065
1360
|
resolveMx,
|
|
1066
1361
|
sendMail
|
|
1067
1362
|
};
|
|
1068
1363
|
|
|
1069
|
-
//# debugId=
|
|
1364
|
+
//# debugId=CDDCD67CAFC1023D64756E2164756E21
|
|
1070
1365
|
//# sourceMappingURL=index.js.map
|