@bumail/smtp 0.11.1 → 0.12.1
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 +679 -344
- package/dist/client/index.js.map +20 -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-waits.d.ts +36 -0
- package/dist/client/socket-waits.d.ts.map +1 -0
- package/dist/client/socket.d.ts +14 -4
- 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 +10 -4
- package/docs/troubleshooting.md +84 -0
- package/package.json +2 -2
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,47 +478,151 @@ class Clock {
|
|
|
675
478
|
}
|
|
676
479
|
}
|
|
677
480
|
|
|
678
|
-
// src/client/socket.ts
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
+
|
|
497
|
+
// src/client/socket-waits.ts
|
|
498
|
+
class Waits {
|
|
499
|
+
hooks;
|
|
688
500
|
#wake;
|
|
689
|
-
#
|
|
690
|
-
#
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
constructor(host, clock) {
|
|
694
|
-
this.host = host;
|
|
695
|
-
this.#clock = clock;
|
|
501
|
+
#watchers = new Set;
|
|
502
|
+
#replies = 0;
|
|
503
|
+
get replies() {
|
|
504
|
+
return this.#replies;
|
|
696
505
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
506
|
+
constructor(hooks) {
|
|
507
|
+
this.hooks = hooks;
|
|
508
|
+
}
|
|
509
|
+
wakeUp() {
|
|
510
|
+
const wake = this.#wake;
|
|
511
|
+
this.#wake = undefined;
|
|
512
|
+
wake?.();
|
|
513
|
+
}
|
|
514
|
+
release() {
|
|
515
|
+
for (const watcher of this.#watchers)
|
|
516
|
+
watcher();
|
|
517
|
+
}
|
|
518
|
+
race(promise, seconds, what, start = performance.now()) {
|
|
519
|
+
const ms = start + seconds * 1000 - performance.now();
|
|
520
|
+
const { host, fail } = this.hooks;
|
|
521
|
+
const clock = this.hooks.clock();
|
|
522
|
+
return clock.race(promise, ms, (passed) => fail(timedOut(host, what, seconds, passed ? clock.seconds : undefined)));
|
|
523
|
+
}
|
|
524
|
+
async reply(what, seconds) {
|
|
525
|
+
const { inbox, failure, closed, fail, host } = this.hooks;
|
|
526
|
+
const start = performance.now();
|
|
527
|
+
for (;; ) {
|
|
528
|
+
const next = inbox.shift();
|
|
529
|
+
if (next) {
|
|
530
|
+
this.#replies++;
|
|
531
|
+
return next;
|
|
532
|
+
}
|
|
533
|
+
const failed = failure();
|
|
534
|
+
if (failed)
|
|
535
|
+
throw failed;
|
|
536
|
+
if (closed())
|
|
537
|
+
throw fail(lost(host, what));
|
|
538
|
+
await this.race(new Promise((wake) => {
|
|
539
|
+
this.#wake = wake;
|
|
540
|
+
}), seconds, what, start);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
within(promise, seconds, what) {
|
|
544
|
+
const { host, closed, hungUp } = this.hooks;
|
|
545
|
+
const watcher = Promise.withResolvers();
|
|
546
|
+
if (closed())
|
|
547
|
+
watcher.resolve();
|
|
548
|
+
else
|
|
549
|
+
this.#watchers.add(watcher.resolve);
|
|
550
|
+
const gone = watcher.promise;
|
|
551
|
+
let over = false;
|
|
552
|
+
const hung = gone.then(() => {
|
|
553
|
+
if (!over)
|
|
554
|
+
throw hungUp(lost(host, what));
|
|
555
|
+
});
|
|
556
|
+
const race = Promise.race([promise, hung]);
|
|
557
|
+
race.catch(() => {
|
|
558
|
+
return;
|
|
559
|
+
});
|
|
560
|
+
hung.catch(() => {
|
|
561
|
+
return;
|
|
562
|
+
});
|
|
563
|
+
return this.race(race, seconds, what).finally(() => {
|
|
564
|
+
over = true;
|
|
565
|
+
this.#watchers.delete(watcher.resolve);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/client/socket.ts
|
|
571
|
+
class ClientSocket {
|
|
572
|
+
host;
|
|
573
|
+
clock;
|
|
574
|
+
#socket;
|
|
575
|
+
#outgoing = new Outgoing(() => 0);
|
|
576
|
+
#tls = false;
|
|
577
|
+
#inbox = new Inbox;
|
|
578
|
+
#failure;
|
|
579
|
+
#closed = false;
|
|
580
|
+
#handshaking;
|
|
581
|
+
#waits;
|
|
582
|
+
secure = false;
|
|
583
|
+
get replies() {
|
|
584
|
+
return this.#waits.replies;
|
|
585
|
+
}
|
|
586
|
+
verified = false;
|
|
587
|
+
constructor(host, clock) {
|
|
588
|
+
this.host = host;
|
|
589
|
+
this.clock = clock;
|
|
590
|
+
this.#waits = new Waits({
|
|
591
|
+
host,
|
|
592
|
+
clock: () => this.clock,
|
|
593
|
+
inbox: this.#inbox,
|
|
594
|
+
failure: () => this.#failure,
|
|
595
|
+
closed: () => this.#closed,
|
|
596
|
+
fail: (error) => this.#fail(error),
|
|
597
|
+
hungUp: (otherwise) => this.#hungUp(otherwise)
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
reply(what, seconds) {
|
|
601
|
+
return this.#waits.reply(what, seconds);
|
|
602
|
+
}
|
|
603
|
+
within(promise, seconds, what) {
|
|
604
|
+
return this.#waits.within(promise, seconds, what);
|
|
605
|
+
}
|
|
606
|
+
static async open(endpoint, tls, seconds, clock) {
|
|
607
|
+
const client = new ClientSocket(endpoint.host, clock);
|
|
608
|
+
client.#tls = tls !== undefined;
|
|
609
|
+
const handshake = tls ? client.#expectHandshake(tls) : undefined;
|
|
610
|
+
handshake?.catch(() => {});
|
|
611
|
+
const { address, port } = endpoint;
|
|
612
|
+
const connecting = Bun.connect({
|
|
613
|
+
hostname: address,
|
|
705
614
|
port,
|
|
706
615
|
...tls ? { tls: tlsOptions(tls) } : {},
|
|
707
616
|
socket: client.#handlers(tls !== undefined)
|
|
708
617
|
});
|
|
709
618
|
try {
|
|
710
|
-
client.#use(await client.#race(connecting, seconds, "the connection"));
|
|
619
|
+
client.#use(await client.#waits.race(connecting, seconds, "the connection"));
|
|
711
620
|
} catch (error) {
|
|
712
621
|
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)}`);
|
|
622
|
+
throw connectFailure(endpoint, error);
|
|
716
623
|
}
|
|
717
624
|
if (handshake) {
|
|
718
|
-
await client.#race(handshake, seconds, "the TLS handshake").catch((e) => {
|
|
625
|
+
await client.#waits.race(handshake, seconds, "the TLS handshake").catch((e) => {
|
|
719
626
|
client.close(true);
|
|
720
627
|
throw e;
|
|
721
628
|
});
|
|
@@ -727,17 +634,12 @@ class ClientSocket {
|
|
|
727
634
|
this.#outgoing = new Outgoing((bytes) => socket.write(bytes));
|
|
728
635
|
}
|
|
729
636
|
#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
|
-
};
|
|
637
|
+
return socketHandlers(() => this.#tls === tls, {
|
|
638
|
+
receive: (chunk) => this.#receive(chunk),
|
|
639
|
+
drain: () => this.#outgoing.drain(),
|
|
640
|
+
lost: () => this.#lost(),
|
|
641
|
+
handshake: (ok, error) => this.#handshaking?.(ok, error)
|
|
642
|
+
});
|
|
741
643
|
}
|
|
742
644
|
#expectHandshake(tls) {
|
|
743
645
|
return new Promise((resolve, reject) => {
|
|
@@ -763,13 +665,11 @@ class ClientSocket {
|
|
|
763
665
|
this.#wakeUp();
|
|
764
666
|
}
|
|
765
667
|
#wakeUp() {
|
|
766
|
-
|
|
767
|
-
this.#wake = undefined;
|
|
768
|
-
wake?.();
|
|
668
|
+
this.#waits.wakeUp();
|
|
769
669
|
}
|
|
770
670
|
#lost() {
|
|
771
671
|
this.#closed = true;
|
|
772
|
-
this.#
|
|
672
|
+
this.#waits.release();
|
|
773
673
|
this.#outgoing.clear();
|
|
774
674
|
this.#handshaking?.(false, null);
|
|
775
675
|
this.#wakeUp();
|
|
@@ -780,39 +680,6 @@ class ClientSocket {
|
|
|
780
680
|
this.#lost();
|
|
781
681
|
return this.#failure;
|
|
782
682
|
}
|
|
783
|
-
#race(promise, seconds, what, start = performance.now()) {
|
|
784
|
-
const ms = start + seconds * 1000 - performance.now();
|
|
785
|
-
const { host } = this;
|
|
786
|
-
const deadline = this.#clock.seconds;
|
|
787
|
-
return this.#clock.race(promise, ms, (passed) => this.#fail(timedOut(host, what, seconds, passed ? deadline : undefined)));
|
|
788
|
-
}
|
|
789
|
-
async reply(what, seconds) {
|
|
790
|
-
const start = performance.now();
|
|
791
|
-
for (;; ) {
|
|
792
|
-
const next = this.#inbox.shift();
|
|
793
|
-
if (next)
|
|
794
|
-
return next;
|
|
795
|
-
if (this.#failure)
|
|
796
|
-
throw this.#failure;
|
|
797
|
-
if (this.#closed)
|
|
798
|
-
throw this.#fail(lost(this.host, what));
|
|
799
|
-
await this.#race(new Promise((wake) => {
|
|
800
|
-
this.#wake = wake;
|
|
801
|
-
}), seconds, what, start);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
within(promise, seconds, what) {
|
|
805
|
-
const over = new AbortController;
|
|
806
|
-
const gone = this.#gone.promise.then(() => {
|
|
807
|
-
if (!over.signal.aborted)
|
|
808
|
-
throw this.#hungUp(lost(this.host, what));
|
|
809
|
-
});
|
|
810
|
-
const race = Promise.race([promise, gone]);
|
|
811
|
-
race.catch(() => {
|
|
812
|
-
return;
|
|
813
|
-
});
|
|
814
|
-
return this.#race(race, seconds, what).finally(() => over.abort());
|
|
815
|
-
}
|
|
816
683
|
write(data) {
|
|
817
684
|
if (this.#failure || this.#closed)
|
|
818
685
|
return false;
|
|
@@ -820,7 +687,7 @@ class ClientSocket {
|
|
|
820
687
|
return true;
|
|
821
688
|
}
|
|
822
689
|
async drained(seconds) {
|
|
823
|
-
await this.#race(this.#outgoing.drained(), seconds, "the server to take the message");
|
|
690
|
+
await this.#waits.race(this.#outgoing.drained(), seconds, "the server to take the message");
|
|
824
691
|
if (this.#failure || this.#closed)
|
|
825
692
|
throw this.#hungUp(lost(this.host));
|
|
826
693
|
}
|
|
@@ -843,7 +710,19 @@ class ClientSocket {
|
|
|
843
710
|
socket: this.#handlers(true)
|
|
844
711
|
});
|
|
845
712
|
this.#use(encrypted);
|
|
846
|
-
await this.#race(handshake, seconds, "the TLS handshake");
|
|
713
|
+
await this.#waits.race(handshake, seconds, "the TLS handshake");
|
|
714
|
+
}
|
|
715
|
+
get quiet() {
|
|
716
|
+
return !this.#failure && !this.#closed && !this.#inbox.holding;
|
|
717
|
+
}
|
|
718
|
+
ref(keep) {
|
|
719
|
+
if (keep)
|
|
720
|
+
this.#socket?.ref();
|
|
721
|
+
else
|
|
722
|
+
this.#socket?.unref();
|
|
723
|
+
}
|
|
724
|
+
newMessage() {
|
|
725
|
+
this.#inbox.reset();
|
|
847
726
|
}
|
|
848
727
|
close(now = false) {
|
|
849
728
|
this.#closed = true;
|
|
@@ -854,7 +733,246 @@ class ClientSocket {
|
|
|
854
733
|
}
|
|
855
734
|
}
|
|
856
735
|
|
|
736
|
+
// src/client/connect.ts
|
|
737
|
+
var early = new WeakSet;
|
|
738
|
+
async function connect(endpoint, settings, clock) {
|
|
739
|
+
const tls = {
|
|
740
|
+
servername: endpoint.host,
|
|
741
|
+
verify: settings.tls === "required",
|
|
742
|
+
...settings.ca ? { ca: settings.ca } : {}
|
|
743
|
+
};
|
|
744
|
+
let socket;
|
|
745
|
+
try {
|
|
746
|
+
socket = await ClientSocket.open(endpoint, settings.secure ? tls : undefined, settings.timeouts.connect, clock);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
early.add(error);
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
const ready = await open(socket, settings, tls);
|
|
753
|
+
return { socket, ready };
|
|
754
|
+
} catch (error) {
|
|
755
|
+
early.add(error);
|
|
756
|
+
socket.write(`QUIT\r
|
|
757
|
+
`);
|
|
758
|
+
socket.close();
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function resultOf(delivered, { socket, ready }, endpoint) {
|
|
763
|
+
return {
|
|
764
|
+
...delivered,
|
|
765
|
+
host: endpoint.host,
|
|
766
|
+
port: endpoint.port,
|
|
767
|
+
tls: socket.secure ? { verified: socket.verified } : false,
|
|
768
|
+
authenticated: ready.authenticated
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/client/settings.ts
|
|
773
|
+
import { hostname as machineName } from "node:os";
|
|
774
|
+
|
|
775
|
+
// src/client/dsn.ts
|
|
776
|
+
var EVENTS = ["SUCCESS", "FAILURE", "DELAY"];
|
|
777
|
+
function dsnOf(given, to, fail) {
|
|
778
|
+
const { ret, envid, recipients } = given;
|
|
779
|
+
if (ret !== undefined && ret !== "FULL" && ret !== "HDRS")
|
|
780
|
+
throw fail(`ret must be 'FULL' or 'HDRS', not ${String(ret)}`);
|
|
781
|
+
if (envid !== undefined && (typeof envid !== "string" || !/^[ -~]{1,100}$/.test(envid) || encodeXtext(envid).length > 100))
|
|
782
|
+
throw fail("envid must be 1 to 100 printable ASCII characters, at most 100 once encoded as xtext");
|
|
783
|
+
const byAddress = new Map;
|
|
784
|
+
if (recipients !== undefined) {
|
|
785
|
+
if (!Array.isArray(recipients))
|
|
786
|
+
throw fail("recipients must be an array of { address, notify, orcpt }");
|
|
787
|
+
for (const entry of recipients) {
|
|
788
|
+
const address = entry?.address;
|
|
789
|
+
if (typeof address !== "string" || !to.includes(address))
|
|
790
|
+
throw fail(`recipients names ${JSON.stringify(address)}, which is not in to`);
|
|
791
|
+
if (byAddress.has(address))
|
|
792
|
+
throw fail(`recipients names ${address} twice`);
|
|
793
|
+
checkRecipient(entry, fail);
|
|
794
|
+
byAddress.set(address, entry);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
...ret ? { ret } : {},
|
|
799
|
+
...envid === undefined ? {} : { envid },
|
|
800
|
+
recipients: byAddress
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function checkRecipient({ address, notify, orcpt }, fail) {
|
|
804
|
+
if (notify !== undefined && notify !== "NEVER") {
|
|
805
|
+
const events = Array.isArray(notify) ? notify : [];
|
|
806
|
+
if (events.length === 0 || events.some((e, i) => !EVENTS.includes(e) || events.indexOf(e) !== i))
|
|
807
|
+
throw fail(`notify of ${address} must be 'NEVER' or a list of 'SUCCESS', 'FAILURE', 'DELAY', each once`);
|
|
808
|
+
}
|
|
809
|
+
if (orcpt !== undefined) {
|
|
810
|
+
if (typeof orcpt !== "object" || orcpt === null || typeof orcpt.type !== "string" || !/^[A-Za-z0-9-]+$/.test(orcpt.type))
|
|
811
|
+
throw fail(`orcpt of ${address} must be { type, address }, the type a token`);
|
|
812
|
+
if (typeof orcpt.address !== "string" || orcpt.address === "")
|
|
813
|
+
throw fail(`orcpt.address of ${address} must be a non-empty string`);
|
|
814
|
+
const encoded = orcpt.type.toLowerCase() === "utf-8" ? utf8OrcptLength(orcpt.address) : encodeXtext(orcpt.address).length;
|
|
815
|
+
if (orcpt.type.length + 1 + encoded > 500)
|
|
816
|
+
throw fail(`orcpt of ${address} is over 500 characters once encoded`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function mailDsn(dsn, offered) {
|
|
820
|
+
if (!offered)
|
|
821
|
+
return "";
|
|
822
|
+
let out = "";
|
|
823
|
+
if (dsn.ret)
|
|
824
|
+
out += ` RET=${dsn.ret}`;
|
|
825
|
+
if (dsn.envid !== undefined)
|
|
826
|
+
out += ` ENVID=${encodeXtext(dsn.envid)}`;
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
function orcptParameter({ type, address }, smtputf8) {
|
|
830
|
+
const kind = type.toLowerCase();
|
|
831
|
+
if (kind === "rfc822")
|
|
832
|
+
return /^[\x20-\x7e]+$/.test(address) ? ` ORCPT=${type};${encodeXtext(address)}` : "";
|
|
833
|
+
if (kind !== "utf-8") {
|
|
834
|
+
const value = `${type};${encodeXtext(address)}`;
|
|
835
|
+
return address.isWellFormed() ? ` ORCPT=${value}` : "";
|
|
836
|
+
}
|
|
837
|
+
const encoded = encodeUtf8Orcpt(address, smtputf8);
|
|
838
|
+
if (encoded === undefined)
|
|
839
|
+
return "";
|
|
840
|
+
const value = `${type};${encoded}`;
|
|
841
|
+
return new TextEncoder().encode(value).length > 500 ? "" : ` ORCPT=${value}`;
|
|
842
|
+
}
|
|
843
|
+
function rcptDsn(dsn, address, offered, smtputf8 = false) {
|
|
844
|
+
const entry = dsn.recipients.get(address);
|
|
845
|
+
if (!offered || !entry)
|
|
846
|
+
return "";
|
|
847
|
+
let out = "";
|
|
848
|
+
const { notify, orcpt } = entry;
|
|
849
|
+
if (notify !== undefined)
|
|
850
|
+
out += ` NOTIFY=${notify === "NEVER" ? "NEVER" : notify.join(",")}`;
|
|
851
|
+
if (orcpt)
|
|
852
|
+
out += orcptParameter(orcpt, smtputf8);
|
|
853
|
+
return out;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/client/settings.ts
|
|
857
|
+
var MAX_SECONDS = Math.floor(2147483647 / 1000);
|
|
858
|
+
var TIMEOUTS = {
|
|
859
|
+
connect: 30,
|
|
860
|
+
greeting: 300,
|
|
861
|
+
command: 300,
|
|
862
|
+
mail: 300,
|
|
863
|
+
rcpt: 300,
|
|
864
|
+
dataStart: 120,
|
|
865
|
+
dataBlock: 180,
|
|
866
|
+
dataEnd: 600
|
|
867
|
+
};
|
|
868
|
+
var invalid = (message) => new SmtpError2("INVALID_OPTION", `sendMail(): ${message}`);
|
|
869
|
+
function seconds(name, value, fallback) {
|
|
870
|
+
if (value === undefined)
|
|
871
|
+
return fallback;
|
|
872
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_SECONDS) {
|
|
873
|
+
throw invalid(`${name} must be a number of seconds above 0 and at most ${MAX_SECONDS}, not ${String(value)}`);
|
|
874
|
+
}
|
|
875
|
+
return value;
|
|
876
|
+
}
|
|
877
|
+
function timeoutsOf(given = {}) {
|
|
878
|
+
const out = {};
|
|
879
|
+
for (const [name, fallback] of Object.entries(TIMEOUTS)) {
|
|
880
|
+
out[name] = seconds(`timeouts.${name}`, given[name], fallback);
|
|
881
|
+
}
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
function address(value, allowNull) {
|
|
885
|
+
if (allowNull && value === "")
|
|
886
|
+
return "";
|
|
887
|
+
if (isMailbox(value))
|
|
888
|
+
return value;
|
|
889
|
+
const text = typeof value === "string" ? value : "";
|
|
890
|
+
const routed = typeof value === "string" && parsePath2(`<${text}>`, false);
|
|
891
|
+
if (routed) {
|
|
892
|
+
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`);
|
|
893
|
+
}
|
|
894
|
+
throw invalid(`${JSON.stringify(value)} is not an address (local@domain${allowNull ? ", or '' for a bounce" : ""})`);
|
|
895
|
+
}
|
|
896
|
+
function heloOf(value, byMx) {
|
|
897
|
+
if (value !== undefined) {
|
|
898
|
+
if (!isHelloName(value))
|
|
899
|
+
throw invalid(`helo "${value}" is not a host name`);
|
|
900
|
+
return value;
|
|
901
|
+
}
|
|
902
|
+
if (byMx)
|
|
903
|
+
throw invalid("helo is required for delivery by MX: pass your server's public name, such as helo: 'mail.example.com'");
|
|
904
|
+
const name = machineName();
|
|
905
|
+
return isHelloName(name) ? name : "localhost";
|
|
906
|
+
}
|
|
907
|
+
function authOf(auth) {
|
|
908
|
+
if (auth === undefined)
|
|
909
|
+
return;
|
|
910
|
+
const { username, password, mechanism } = auth;
|
|
911
|
+
if (typeof username !== "string" || username === "")
|
|
912
|
+
throw invalid("auth.username must be a non-empty string");
|
|
913
|
+
if (typeof password !== "string" || password === "")
|
|
914
|
+
throw invalid("auth.password must be a non-empty string");
|
|
915
|
+
if (mechanism !== undefined && mechanism !== "PLAIN" && mechanism !== "LOGIN")
|
|
916
|
+
throw invalid(`auth.mechanism must be 'PLAIN' or 'LOGIN', not ${mechanism}`);
|
|
917
|
+
return auth;
|
|
918
|
+
}
|
|
919
|
+
function tlsOf(options, plaintextAuth) {
|
|
920
|
+
const secure = options.secure === true;
|
|
921
|
+
const tls = options.tls ?? (secure || options.auth && !plaintextAuth ? "required" : "opportunistic");
|
|
922
|
+
if (tls !== "opportunistic" && tls !== "required" && tls !== "none")
|
|
923
|
+
throw invalid(`tls must be 'opportunistic', 'required' or 'none', not ${tls}`);
|
|
924
|
+
if (tls === "none" && secure)
|
|
925
|
+
throw invalid("secure is TLS from the first byte: it cannot go with tls: 'none'");
|
|
926
|
+
if (tls === "none" && options.auth && !plaintextAuth)
|
|
927
|
+
throw invalid("auth with tls: 'none' would send the password in clear; pass allowPlaintextAuth: true for a local test server");
|
|
928
|
+
if (tls === "opportunistic" && options.auth && !plaintextAuth)
|
|
929
|
+
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");
|
|
930
|
+
return tls;
|
|
931
|
+
}
|
|
932
|
+
function settingsOf(options) {
|
|
933
|
+
if (typeof options !== "object" || options === null)
|
|
934
|
+
throw invalid("options must be an object");
|
|
935
|
+
const from = address(options.from, true);
|
|
936
|
+
const list = typeof options.to === "string" ? [options.to] : options.to;
|
|
937
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
938
|
+
throw invalid("to must name one recipient or more");
|
|
939
|
+
const to = list.map((recipient) => address(recipient, false));
|
|
940
|
+
const allowPlaintextAuth = options.allowPlaintextAuth === true;
|
|
941
|
+
const { size } = options;
|
|
942
|
+
if (size !== undefined && (!Number.isSafeInteger(size) || size < 0))
|
|
943
|
+
throw invalid(`size must be a number of bytes, not ${size}`);
|
|
944
|
+
const ca = options.ca === undefined ? undefined : [options.ca].flat();
|
|
945
|
+
const auth = authOf(options.auth);
|
|
946
|
+
return {
|
|
947
|
+
from,
|
|
948
|
+
to,
|
|
949
|
+
helo: heloOf(options.helo, byMx(options)),
|
|
950
|
+
tls: tlsOf(options, allowPlaintextAuth),
|
|
951
|
+
secure: options.secure === true,
|
|
952
|
+
...ca ? { ca } : {},
|
|
953
|
+
...auth ? { auth } : {},
|
|
954
|
+
allowPlaintextAuth,
|
|
955
|
+
...size === undefined ? {} : { size },
|
|
956
|
+
dsn: dsnOf(options, to, invalid),
|
|
957
|
+
smtputf8: options.smtputf8 === true || [from, ...to].some((text) => /[^ -~]/.test(text)),
|
|
958
|
+
normalizeLineEnds: options.normalizeLineEnds === true,
|
|
959
|
+
timeouts: timeoutsOf(options.timeouts),
|
|
960
|
+
deadline: seconds("deadline", options.deadline, 1800)
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
var byMx = (options) => !(("host" in options) && options.host !== undefined);
|
|
964
|
+
function portOf(port, fallback) {
|
|
965
|
+
if (port === undefined)
|
|
966
|
+
return fallback;
|
|
967
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
968
|
+
throw invalid(`port must be an integer from 1 to 65535, not ${port}`);
|
|
969
|
+
return port;
|
|
970
|
+
}
|
|
971
|
+
|
|
857
972
|
// src/client/transaction.ts
|
|
973
|
+
var stepped = new WeakSet;
|
|
974
|
+
var broken = new WeakSet;
|
|
975
|
+
var inStep = (error) => typeof error === "object" && error !== null && stepped.has(error);
|
|
858
976
|
var missing = (host, extension, why) => new SmtpError2("EXTENSION_MISSING", `${host} does not offer ${extension}, which ${why}`);
|
|
859
977
|
function mailParameters(host, settings, content, extensions) {
|
|
860
978
|
let parameters = "";
|
|
@@ -883,15 +1001,34 @@ async function envelope(socket, settings, content, ready) {
|
|
|
883
1001
|
const { host } = socket;
|
|
884
1002
|
const { timeouts } = settings;
|
|
885
1003
|
const dsn = ready.extensions.has("DSN");
|
|
886
|
-
|
|
1004
|
+
let parameters;
|
|
1005
|
+
try {
|
|
1006
|
+
parameters = mailParameters(host, settings, content, ready.extensions);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
stepped.add(error);
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
1011
|
+
const mail = `MAIL FROM:<${settings.from}>${parameters}${mailDsn(settings.dsn, dsn)}\r
|
|
887
1012
|
`;
|
|
888
1013
|
const rcpts = settings.to.map((to) => `RCPT TO:<${to}>${rcptDsn(settings.dsn, to, dsn, settings.smtputf8)}\r
|
|
889
1014
|
`);
|
|
890
1015
|
const pipelining = ready.extensions.has("PIPELINING");
|
|
891
1016
|
socket.write(pipelining ? mail + rcpts.join("") : mail);
|
|
892
1017
|
const from = await socket.reply("the reply to MAIL FROM", timeouts.mail);
|
|
893
|
-
if (from.code !== 250)
|
|
894
|
-
|
|
1018
|
+
if (from.code !== 250) {
|
|
1019
|
+
const refusal = refused(host, "the sender", from);
|
|
1020
|
+
if (pipelining) {
|
|
1021
|
+
for (const _ of rcpts) {
|
|
1022
|
+
try {
|
|
1023
|
+
await socket.reply("the reply to RCPT TO", timeouts.rcpt);
|
|
1024
|
+
} catch {
|
|
1025
|
+
broken.add(refusal);
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
throw refusal;
|
|
1031
|
+
}
|
|
895
1032
|
const accepted = [];
|
|
896
1033
|
const rejected = [];
|
|
897
1034
|
for (const [i, recipient] of settings.to.entries()) {
|
|
@@ -954,63 +1091,49 @@ async function data(socket, settings, content, ready, taken) {
|
|
|
954
1091
|
return end;
|
|
955
1092
|
}
|
|
956
1093
|
async function transact(socket, settings, content, ready) {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1094
|
+
try {
|
|
1095
|
+
const { accepted, rejected } = await envelope(socket, settings, content, ready);
|
|
1096
|
+
const reply = await data(socket, settings, content, ready, {
|
|
1097
|
+
accepted,
|
|
1098
|
+
rejected
|
|
1099
|
+
});
|
|
1100
|
+
const delivered = {
|
|
1101
|
+
accepted,
|
|
1102
|
+
rejected,
|
|
1103
|
+
reply,
|
|
1104
|
+
dsnOffered: ready.extensions.has("DSN")
|
|
1105
|
+
};
|
|
1106
|
+
return { delivered };
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
const code = error instanceof SmtpError2 ? error.reply?.code : undefined;
|
|
1109
|
+
if (code !== undefined && code >= 400 && code < 600 && code !== 421 && !broken.has(error))
|
|
1110
|
+
stepped.add(error);
|
|
1111
|
+
throw error;
|
|
1112
|
+
}
|
|
968
1113
|
}
|
|
969
1114
|
|
|
970
1115
|
// src/client/send.ts
|
|
971
1116
|
var MAX_ADDRESSES = 10;
|
|
972
1117
|
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
|
-
}
|
|
1118
|
+
var attempt = async (endpoint, settings, content, clock) => {
|
|
1119
|
+
const { socket, ready } = await connect(endpoint, settings, clock);
|
|
987
1120
|
let quit = false;
|
|
988
1121
|
try {
|
|
989
|
-
const
|
|
990
|
-
early.add(error);
|
|
991
|
-
throw error;
|
|
992
|
-
});
|
|
993
|
-
const delivered = await transact(socket, settings, content, ready);
|
|
1122
|
+
const { delivered } = await transact(socket, settings, content, ready);
|
|
994
1123
|
socket.write(`QUIT\r
|
|
995
1124
|
`);
|
|
996
1125
|
quit = true;
|
|
997
1126
|
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
|
-
};
|
|
1127
|
+
return resultOf(delivered, { socket, ready }, endpoint);
|
|
1005
1128
|
} finally {
|
|
1006
1129
|
if (!quit)
|
|
1007
1130
|
socket.write(`QUIT\r
|
|
1008
1131
|
`);
|
|
1009
1132
|
socket.close();
|
|
1010
1133
|
}
|
|
1011
|
-
}
|
|
1134
|
+
};
|
|
1012
1135
|
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) {
|
|
1136
|
+
async function viaMx(try_, options, settings, content, clock) {
|
|
1014
1137
|
const { domain, resolver } = options;
|
|
1015
1138
|
if (typeof domain !== "string" || domain === "")
|
|
1016
1139
|
throw invalid("domain must be a domain name");
|
|
@@ -1035,7 +1158,7 @@ async function viaMx(options, settings, content, clock) {
|
|
|
1035
1158
|
if (tried++ >= MAX_ADDRESSES)
|
|
1036
1159
|
break hosts;
|
|
1037
1160
|
try {
|
|
1038
|
-
return await
|
|
1161
|
+
return await try_({ host: mx.host, address, port }, settings, content, clock);
|
|
1039
1162
|
} catch (error) {
|
|
1040
1163
|
const next = error instanceof SmtpError2 && error.temporary && early.has(error);
|
|
1041
1164
|
if (!next)
|
|
@@ -1046,25 +1169,237 @@ async function viaMx(options, settings, content, clock) {
|
|
|
1046
1169
|
}
|
|
1047
1170
|
throw last ?? noAddress(domain, dnsError);
|
|
1048
1171
|
}
|
|
1049
|
-
|
|
1050
|
-
const settings = settingsOf(options);
|
|
1051
|
-
const content = contentOf(message, settings.normalizeLineEnds, settings.size);
|
|
1052
|
-
const clock = new Clock(settings.deadline);
|
|
1172
|
+
function deliver(try_, options, settings, content, clock) {
|
|
1053
1173
|
if (!byMx(options)) {
|
|
1054
1174
|
const { host } = options;
|
|
1055
1175
|
if (typeof host !== "string" || !/^[^\s/]+$/.test(host))
|
|
1056
1176
|
throw invalid(`host must be a host name or an address, not ${host}`);
|
|
1057
1177
|
const port = portOf(options.port, settings.secure ? 465 : 25);
|
|
1058
|
-
return
|
|
1178
|
+
return try_({ host, address: host, port }, settings, content, clock);
|
|
1059
1179
|
}
|
|
1060
|
-
return viaMx(options, settings, content, clock);
|
|
1180
|
+
return viaMx(try_, options, settings, content, clock);
|
|
1181
|
+
}
|
|
1182
|
+
async function sendMail(message, options) {
|
|
1183
|
+
const settings = settingsOf(options);
|
|
1184
|
+
const content = contentOf(message, settings.normalizeLineEnds, settings.size);
|
|
1185
|
+
return deliver(attempt, options, settings, content, new Clock(settings.deadline));
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/client/pool/options.ts
|
|
1189
|
+
var invalid2 = (message) => new SmtpError2("INVALID_OPTION", `createSmtpPool(): ${message}`);
|
|
1190
|
+
function count(name, value, fallback) {
|
|
1191
|
+
if (value === undefined)
|
|
1192
|
+
return fallback;
|
|
1193
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
1194
|
+
throw invalid2(`${name} must be an integer of at least 1, not ${String(value)}`);
|
|
1195
|
+
return value;
|
|
1196
|
+
}
|
|
1197
|
+
function seconds2(name, value, fallback) {
|
|
1198
|
+
if (value === undefined)
|
|
1199
|
+
return fallback;
|
|
1200
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_SECONDS)
|
|
1201
|
+
throw invalid2(`${name} must be a number of seconds above 0 and at most ${MAX_SECONDS}, not ${String(value)}`);
|
|
1202
|
+
return value;
|
|
1203
|
+
}
|
|
1204
|
+
function limitsOf(options = {}) {
|
|
1205
|
+
if (typeof options !== "object" || options === null)
|
|
1206
|
+
throw invalid2("options must be an object");
|
|
1207
|
+
return {
|
|
1208
|
+
maxMessages: count("maxMessages", options.maxMessages, 100),
|
|
1209
|
+
idleSeconds: seconds2("idleSeconds", options.idleSeconds, 5),
|
|
1210
|
+
maxAgeSeconds: seconds2("maxAgeSeconds", options.maxAgeSeconds, 300),
|
|
1211
|
+
maxIdlePerHost: count("maxIdlePerHost", options.maxIdlePerHost, 4)
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
var sameList = (a, b) => a === b || a !== undefined && b !== undefined && a.length === b.length && a.every((item, i) => item === b[i]);
|
|
1215
|
+
var sameAuth = (a, b) => a === b || a !== undefined && b !== undefined && a.username === b.username && a.password === b.password && a.mechanism === b.mechanism;
|
|
1216
|
+
function sameIdentity(a, b) {
|
|
1217
|
+
const x = a.connection;
|
|
1218
|
+
const y = b.connection;
|
|
1219
|
+
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);
|
|
1220
|
+
}
|
|
1221
|
+
var identityOf = (endpoint, settings) => ({
|
|
1222
|
+
host: endpoint.host,
|
|
1223
|
+
address: endpoint.address,
|
|
1224
|
+
port: endpoint.port,
|
|
1225
|
+
connection: {
|
|
1226
|
+
helo: settings.helo,
|
|
1227
|
+
tls: settings.tls,
|
|
1228
|
+
secure: settings.secure,
|
|
1229
|
+
allowPlaintextAuth: settings.allowPlaintextAuth,
|
|
1230
|
+
...settings.ca ? { ca: [...settings.ca] } : {},
|
|
1231
|
+
...settings.auth ? { auth: { ...settings.auth } } : {}
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
// src/client/pool/reset.ts
|
|
1236
|
+
var RESET_WAIT = 5;
|
|
1237
|
+
async function reset(entry, clock, seconds) {
|
|
1238
|
+
const { socket } = entry;
|
|
1239
|
+
if (!socket.quiet)
|
|
1240
|
+
return false;
|
|
1241
|
+
socket.clock = clock;
|
|
1242
|
+
socket.write(`RSET\r
|
|
1243
|
+
`);
|
|
1244
|
+
try {
|
|
1245
|
+
return (await socket.reply("the reply to RSET", seconds)).code === 250;
|
|
1246
|
+
} catch {
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
var stale = (entry, before, error) => error instanceof SmtpError2 && error.code === "CONNECTION_LOST" && error.reply === undefined && !error.afterData && entry.socket.replies === before;
|
|
1251
|
+
|
|
1252
|
+
// src/client/pool/state.ts
|
|
1253
|
+
var newState = (limits) => ({
|
|
1254
|
+
limits,
|
|
1255
|
+
idle: [],
|
|
1256
|
+
busy: new Set,
|
|
1257
|
+
resetting: new Set,
|
|
1258
|
+
closed: false
|
|
1259
|
+
});
|
|
1260
|
+
function hangUp(entry, polite = true) {
|
|
1261
|
+
clearTimeout(entry.timer);
|
|
1262
|
+
if (polite)
|
|
1263
|
+
entry.socket.write(`QUIT\r
|
|
1264
|
+
`);
|
|
1265
|
+
entry.socket.close();
|
|
1266
|
+
}
|
|
1267
|
+
var aged = (state, entry) => performance.now() - entry.born >= state.limits.maxAgeSeconds * 1000;
|
|
1268
|
+
|
|
1269
|
+
// src/client/pool/idle.ts
|
|
1270
|
+
function forget(state, entry) {
|
|
1271
|
+
const at = state.idle.indexOf(entry);
|
|
1272
|
+
if (at >= 0)
|
|
1273
|
+
state.idle.splice(at, 1);
|
|
1274
|
+
clearTimeout(entry.timer);
|
|
1275
|
+
}
|
|
1276
|
+
function release(state, entry) {
|
|
1277
|
+
const { limits, idle } = state;
|
|
1278
|
+
const spent = state.closed || entry.messages >= limits.maxMessages || aged(state, entry) || !entry.socket.quiet;
|
|
1279
|
+
for (const other of idle.filter((e) => !e.socket.quiet)) {
|
|
1280
|
+
forget(state, other);
|
|
1281
|
+
hangUp(other, false);
|
|
1282
|
+
}
|
|
1283
|
+
const same = idle.filter((e) => e.identity.host === entry.identity.host);
|
|
1284
|
+
if (spent || same.length >= limits.maxIdlePerHost) {
|
|
1285
|
+
hangUp(entry);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
entry.timer = setTimeout(() => {
|
|
1289
|
+
forget(state, entry);
|
|
1290
|
+
hangUp(entry);
|
|
1291
|
+
}, limits.idleSeconds * 1000);
|
|
1292
|
+
entry.timer.unref();
|
|
1293
|
+
entry.socket.ref(false);
|
|
1294
|
+
idle.push(entry);
|
|
1295
|
+
}
|
|
1296
|
+
async function borrow(state, identity, seconds, clock) {
|
|
1297
|
+
for (;; ) {
|
|
1298
|
+
const entry = state.idle.find((e) => sameIdentity(e.identity, identity));
|
|
1299
|
+
if (!entry)
|
|
1300
|
+
return;
|
|
1301
|
+
forget(state, entry);
|
|
1302
|
+
entry.socket.ref(true);
|
|
1303
|
+
if (aged(state, entry)) {
|
|
1304
|
+
hangUp(entry, true);
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
if (await reset(entry, clock, seconds))
|
|
1308
|
+
return entry;
|
|
1309
|
+
hangUp(entry, false);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// src/client/pool/send.ts
|
|
1314
|
+
class Stale extends Error {
|
|
1315
|
+
}
|
|
1316
|
+
function recover(state, entry) {
|
|
1317
|
+
const job = reset(entry, new Clock(RESET_WAIT), RESET_WAIT).then((ok) => ok ? release(state, entry) : hangUp(entry, false)).catch(() => hangUp(entry, false));
|
|
1318
|
+
state.resetting.add(job);
|
|
1319
|
+
job.finally(() => state.resetting.delete(job));
|
|
1320
|
+
}
|
|
1321
|
+
async function send(state, entry, settings, content, clock, reused) {
|
|
1322
|
+
const { socket, ready } = entry;
|
|
1323
|
+
socket.clock = clock;
|
|
1324
|
+
socket.newMessage();
|
|
1325
|
+
const before = socket.replies;
|
|
1326
|
+
let result;
|
|
1327
|
+
try {
|
|
1328
|
+
const { delivered } = await transact(socket, settings, content, ready);
|
|
1329
|
+
result = {
|
|
1330
|
+
...resultOf(delivered, entry, entry.identity),
|
|
1331
|
+
reused
|
|
1332
|
+
};
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (inStep(error)) {
|
|
1335
|
+
entry.messages++;
|
|
1336
|
+
recover(state, entry);
|
|
1337
|
+
} else
|
|
1338
|
+
hangUp(entry);
|
|
1339
|
+
if (reused && stale(entry, before, error))
|
|
1340
|
+
throw new Stale;
|
|
1341
|
+
throw error;
|
|
1342
|
+
}
|
|
1343
|
+
entry.messages++;
|
|
1344
|
+
release(state, entry);
|
|
1345
|
+
return result;
|
|
1346
|
+
}
|
|
1347
|
+
async function attempt2(state, endpoint, settings, content, clock) {
|
|
1348
|
+
const identity = identityOf(endpoint, settings);
|
|
1349
|
+
const wait = Math.min(RESET_WAIT, settings.timeouts.command);
|
|
1350
|
+
const entry = await borrow(state, identity, wait, clock);
|
|
1351
|
+
if (entry) {
|
|
1352
|
+
try {
|
|
1353
|
+
return await send(state, entry, settings, content, clock, true);
|
|
1354
|
+
} catch (error) {
|
|
1355
|
+
if (!(error instanceof Stale))
|
|
1356
|
+
throw error;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const { socket, ready } = await connect(endpoint, settings, clock);
|
|
1360
|
+
const fresh = {
|
|
1361
|
+
socket,
|
|
1362
|
+
ready,
|
|
1363
|
+
identity,
|
|
1364
|
+
born: performance.now(),
|
|
1365
|
+
messages: 0
|
|
1366
|
+
};
|
|
1367
|
+
return send(state, fresh, settings, content, clock, false);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// src/client/pool/index.ts
|
|
1371
|
+
function createSmtpPool(options) {
|
|
1372
|
+
const state = newState(limitsOf(options));
|
|
1373
|
+
return {
|
|
1374
|
+
sendMail(message, sendOptions) {
|
|
1375
|
+
const job = (async () => {
|
|
1376
|
+
if (state.closed)
|
|
1377
|
+
throw new SmtpError2("STOPPED", "The SMTP pool is closed");
|
|
1378
|
+
const settings = settingsOf(sendOptions);
|
|
1379
|
+
const content = contentOf(message, settings.normalizeLineEnds, settings.size);
|
|
1380
|
+
return deliver((endpoint, s, c, clock) => attempt2(state, endpoint, s, c, clock), sendOptions, settings, content, new Clock(settings.deadline));
|
|
1381
|
+
})();
|
|
1382
|
+
state.busy.add(job);
|
|
1383
|
+
const done = () => state.busy.delete(job);
|
|
1384
|
+
job.then(done, done);
|
|
1385
|
+
return job;
|
|
1386
|
+
},
|
|
1387
|
+
async close() {
|
|
1388
|
+
state.closed = true;
|
|
1389
|
+
for (const entry of state.idle.splice(0))
|
|
1390
|
+
hangUp(entry);
|
|
1391
|
+
await Promise.allSettled([...state.busy]);
|
|
1392
|
+
await Promise.allSettled([...state.resetting]);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1061
1395
|
}
|
|
1062
1396
|
export {
|
|
1063
1397
|
SmtpError2 as SmtpError,
|
|
1398
|
+
createSmtpPool,
|
|
1064
1399
|
isMailbox,
|
|
1065
1400
|
resolveMx,
|
|
1066
1401
|
sendMail
|
|
1067
1402
|
};
|
|
1068
1403
|
|
|
1069
|
-
//# debugId=
|
|
1404
|
+
//# debugId=7F1B42BBCDFFD91464756E2164756E21
|
|
1070
1405
|
//# sourceMappingURL=index.js.map
|