@upyo/smtp 0.6.0-dev.271 → 0.6.0-dev.273

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 CHANGED
@@ -26,6 +26,7 @@ Features
26
26
  - HTML and plain text email support
27
27
  - File attachments (regular and inline)
28
28
  - Multiple recipients (To, CC, BCC)
29
+ - SMTP PIPELINING for faster multi-recipient delivery
29
30
  - Custom headers
30
31
  - Priority levels
31
32
  - Comprehensive testing utilities
package/dist/index.cjs CHANGED
@@ -361,6 +361,16 @@ var OAuth2TokenManager = class {
361
361
 
362
362
  //#endregion
363
363
  //#region src/smtp-connection.ts
364
+ var SmtpPipelineTerminatedError = class extends Error {
365
+ responseIndex;
366
+ response;
367
+ constructor(responseIndex, response) {
368
+ super("SMTP pipeline terminated by the server.");
369
+ this.name = "SmtpPipelineTerminatedError";
370
+ this.responseIndex = responseIndex;
371
+ this.response = response;
372
+ }
373
+ };
364
374
  /**
365
375
  * The maximum length of an SMTP command line, including the terminating CRLF,
366
376
  * as specified by RFC 5321 §4.5.3.1.4.
@@ -513,30 +523,59 @@ var SmtpConnection = class {
513
523
  });
514
524
  }
515
525
  sendCommand(command, signal) {
526
+ return this.sendCommands([command], signal).then((responses) => responses[0]);
527
+ }
528
+ /**
529
+ * Sends a group of commands in one write and reads one reply per command.
530
+ *
531
+ * SMTP multiline replies are kept together and complete replies are matched
532
+ * to commands by their position in the returned array, as required for
533
+ * command pipelining by RFC 2920.
534
+ */
535
+ sendCommands(commands, signal) {
516
536
  if (!this.socket) throw new Error("Not connected");
517
537
  signal?.throwIfAborted();
518
538
  return new Promise((resolve, reject) => {
519
539
  let buffer = "";
520
- const timeout = setTimeout(() => {
540
+ let responseLines = [];
541
+ const responses = [];
542
+ const startTimeout = () => setTimeout(() => {
543
+ cleanup();
521
544
  reject(/* @__PURE__ */ new Error("Command timeout"));
522
545
  }, this.config.socketTimeout);
546
+ let timeout = startTimeout();
547
+ const resetTimeout = () => {
548
+ clearTimeout(timeout);
549
+ timeout = startTimeout();
550
+ };
523
551
  const onData = (data) => {
524
552
  buffer += data.toString();
525
553
  const lines = buffer.split("\r\n");
526
554
  const incompleteLine = lines.pop() || "";
527
- for (let i = 0; i < lines.length; i++) {
528
- const line = lines[i];
555
+ for (const line of lines) {
556
+ responseLines.push(line);
529
557
  if (line.length >= 4 && line[3] === " ") {
530
558
  const code = parseInt(line.substring(0, 3), 10);
531
559
  const message = line.substring(4);
532
- const fullResponse = lines.slice(0, i + 1).join("\r\n");
533
- cleanup();
534
- resolve({
560
+ const response = {
535
561
  code,
536
562
  message,
537
- raw: fullResponse
538
- });
539
- return;
563
+ raw: responseLines.join("\r\n")
564
+ };
565
+ const responseIndex = responses.length;
566
+ responses.push(response);
567
+ responseLines = [];
568
+ if (responses.length === commands.length) {
569
+ cleanup();
570
+ resolve(responses);
571
+ return;
572
+ }
573
+ if (response.code === 421) {
574
+ cleanup();
575
+ reject(new SmtpPipelineTerminatedError(responseIndex, response));
576
+ return;
577
+ }
578
+ resetTimeout();
540
579
  }
541
580
  }
542
581
  buffer = incompleteLine;
@@ -545,14 +584,35 @@ var SmtpConnection = class {
545
584
  cleanup();
546
585
  reject(error);
547
586
  };
587
+ const onClose = () => {
588
+ cleanup();
589
+ const responseIndex = responses.findIndex((response) => response.code >= 400);
590
+ if (responseIndex >= 0) {
591
+ reject(new SmtpPipelineTerminatedError(responseIndex, responses[responseIndex]));
592
+ return;
593
+ }
594
+ reject(/* @__PURE__ */ new Error("Connection closed before all command responses."));
595
+ };
596
+ const onAbort = () => {
597
+ cleanup();
598
+ try {
599
+ signal?.throwIfAborted();
600
+ } catch (error) {
601
+ reject(error);
602
+ }
603
+ };
548
604
  const cleanup = () => {
549
605
  clearTimeout(timeout);
550
606
  this.socket?.off("data", onData);
551
607
  this.socket?.off("error", onError);
608
+ this.socket?.off("close", onClose);
609
+ signal?.removeEventListener("abort", onAbort);
552
610
  };
553
611
  this.socket.on("data", onData);
554
612
  this.socket.on("error", onError);
555
- this.socket.write(command + "\r\n");
613
+ this.socket.on("close", onClose);
614
+ signal?.addEventListener("abort", onAbort, { once: true });
615
+ this.socket.write(commands.map((command) => `${command}\r\n`).join(""));
556
616
  });
557
617
  }
558
618
  greeting(signal) {
@@ -600,7 +660,7 @@ var SmtpConnection = class {
600
660
  return;
601
661
  }
602
662
  if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
603
- this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).map((line) => line.substring(4)).filter((line) => line.length > 0);
663
+ this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).slice(1).map((line) => line.substring(4)).filter((line) => line.length > 0);
604
664
  }
605
665
  async starttls(signal) {
606
666
  if (!this.socket) throw new Error("Not connected");
@@ -753,12 +813,31 @@ var SmtpConnection = class {
753
813
  throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
754
814
  }
755
815
  async sendMessage(message, signal) {
756
- const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
816
+ const mailCommand = `MAIL FROM:<${message.envelope.from}>`;
817
+ const recipientCommands = message.envelope.to.map((recipient) => `RCPT TO:<${recipient}>`);
818
+ const pipelining = this.capabilities.some((capability) => /^PIPELINING(?:\s|$)/i.test(capability));
819
+ let mailResponse;
820
+ let recipientResponses;
821
+ if (pipelining) try {
822
+ const envelopeResponses = await this.sendCommands([mailCommand, ...recipientCommands], signal);
823
+ mailResponse = envelopeResponses[0];
824
+ recipientResponses = envelopeResponses.slice(1);
825
+ } catch (error) {
826
+ if (!(error instanceof SmtpPipelineTerminatedError)) throw error;
827
+ const { response, responseIndex } = error;
828
+ if (responseIndex === 0) throw new SmtpResponseError(`MAIL FROM failed: ${response.message}`, response.code, "MAIL FROM", response.message);
829
+ const recipient = message.envelope.to[responseIndex - 1];
830
+ throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${response.message}`, response.code, "RCPT TO", response.message);
831
+ }
832
+ else {
833
+ mailResponse = await this.sendCommand(mailCommand, signal);
834
+ recipientResponses = [];
835
+ }
757
836
  if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
758
837
  const rejectedRecipients = [];
759
- for (const recipient of message.envelope.to) {
838
+ for (const [index, recipient] of message.envelope.to.entries()) {
760
839
  signal?.throwIfAborted();
761
- const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
840
+ const rcptResponse = pipelining ? recipientResponses[index] : await this.sendCommand(recipientCommands[index], signal);
762
841
  if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
763
842
  if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
764
843
  recipient,
package/dist/index.js CHANGED
@@ -338,6 +338,16 @@ var OAuth2TokenManager = class {
338
338
 
339
339
  //#endregion
340
340
  //#region src/smtp-connection.ts
341
+ var SmtpPipelineTerminatedError = class extends Error {
342
+ responseIndex;
343
+ response;
344
+ constructor(responseIndex, response) {
345
+ super("SMTP pipeline terminated by the server.");
346
+ this.name = "SmtpPipelineTerminatedError";
347
+ this.responseIndex = responseIndex;
348
+ this.response = response;
349
+ }
350
+ };
341
351
  /**
342
352
  * The maximum length of an SMTP command line, including the terminating CRLF,
343
353
  * as specified by RFC 5321 §4.5.3.1.4.
@@ -490,30 +500,59 @@ var SmtpConnection = class {
490
500
  });
491
501
  }
492
502
  sendCommand(command, signal) {
503
+ return this.sendCommands([command], signal).then((responses) => responses[0]);
504
+ }
505
+ /**
506
+ * Sends a group of commands in one write and reads one reply per command.
507
+ *
508
+ * SMTP multiline replies are kept together and complete replies are matched
509
+ * to commands by their position in the returned array, as required for
510
+ * command pipelining by RFC 2920.
511
+ */
512
+ sendCommands(commands, signal) {
493
513
  if (!this.socket) throw new Error("Not connected");
494
514
  signal?.throwIfAborted();
495
515
  return new Promise((resolve, reject) => {
496
516
  let buffer = "";
497
- const timeout = setTimeout(() => {
517
+ let responseLines = [];
518
+ const responses = [];
519
+ const startTimeout = () => setTimeout(() => {
520
+ cleanup();
498
521
  reject(/* @__PURE__ */ new Error("Command timeout"));
499
522
  }, this.config.socketTimeout);
523
+ let timeout = startTimeout();
524
+ const resetTimeout = () => {
525
+ clearTimeout(timeout);
526
+ timeout = startTimeout();
527
+ };
500
528
  const onData = (data) => {
501
529
  buffer += data.toString();
502
530
  const lines = buffer.split("\r\n");
503
531
  const incompleteLine = lines.pop() || "";
504
- for (let i = 0; i < lines.length; i++) {
505
- const line = lines[i];
532
+ for (const line of lines) {
533
+ responseLines.push(line);
506
534
  if (line.length >= 4 && line[3] === " ") {
507
535
  const code = parseInt(line.substring(0, 3), 10);
508
536
  const message = line.substring(4);
509
- const fullResponse = lines.slice(0, i + 1).join("\r\n");
510
- cleanup();
511
- resolve({
537
+ const response = {
512
538
  code,
513
539
  message,
514
- raw: fullResponse
515
- });
516
- return;
540
+ raw: responseLines.join("\r\n")
541
+ };
542
+ const responseIndex = responses.length;
543
+ responses.push(response);
544
+ responseLines = [];
545
+ if (responses.length === commands.length) {
546
+ cleanup();
547
+ resolve(responses);
548
+ return;
549
+ }
550
+ if (response.code === 421) {
551
+ cleanup();
552
+ reject(new SmtpPipelineTerminatedError(responseIndex, response));
553
+ return;
554
+ }
555
+ resetTimeout();
517
556
  }
518
557
  }
519
558
  buffer = incompleteLine;
@@ -522,14 +561,35 @@ var SmtpConnection = class {
522
561
  cleanup();
523
562
  reject(error);
524
563
  };
564
+ const onClose = () => {
565
+ cleanup();
566
+ const responseIndex = responses.findIndex((response) => response.code >= 400);
567
+ if (responseIndex >= 0) {
568
+ reject(new SmtpPipelineTerminatedError(responseIndex, responses[responseIndex]));
569
+ return;
570
+ }
571
+ reject(/* @__PURE__ */ new Error("Connection closed before all command responses."));
572
+ };
573
+ const onAbort = () => {
574
+ cleanup();
575
+ try {
576
+ signal?.throwIfAborted();
577
+ } catch (error) {
578
+ reject(error);
579
+ }
580
+ };
525
581
  const cleanup = () => {
526
582
  clearTimeout(timeout);
527
583
  this.socket?.off("data", onData);
528
584
  this.socket?.off("error", onError);
585
+ this.socket?.off("close", onClose);
586
+ signal?.removeEventListener("abort", onAbort);
529
587
  };
530
588
  this.socket.on("data", onData);
531
589
  this.socket.on("error", onError);
532
- this.socket.write(command + "\r\n");
590
+ this.socket.on("close", onClose);
591
+ signal?.addEventListener("abort", onAbort, { once: true });
592
+ this.socket.write(commands.map((command) => `${command}\r\n`).join(""));
533
593
  });
534
594
  }
535
595
  greeting(signal) {
@@ -577,7 +637,7 @@ var SmtpConnection = class {
577
637
  return;
578
638
  }
579
639
  if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
580
- this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).map((line) => line.substring(4)).filter((line) => line.length > 0);
640
+ this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).slice(1).map((line) => line.substring(4)).filter((line) => line.length > 0);
581
641
  }
582
642
  async starttls(signal) {
583
643
  if (!this.socket) throw new Error("Not connected");
@@ -730,12 +790,31 @@ var SmtpConnection = class {
730
790
  throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
731
791
  }
732
792
  async sendMessage(message, signal) {
733
- const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
793
+ const mailCommand = `MAIL FROM:<${message.envelope.from}>`;
794
+ const recipientCommands = message.envelope.to.map((recipient) => `RCPT TO:<${recipient}>`);
795
+ const pipelining = this.capabilities.some((capability) => /^PIPELINING(?:\s|$)/i.test(capability));
796
+ let mailResponse;
797
+ let recipientResponses;
798
+ if (pipelining) try {
799
+ const envelopeResponses = await this.sendCommands([mailCommand, ...recipientCommands], signal);
800
+ mailResponse = envelopeResponses[0];
801
+ recipientResponses = envelopeResponses.slice(1);
802
+ } catch (error) {
803
+ if (!(error instanceof SmtpPipelineTerminatedError)) throw error;
804
+ const { response, responseIndex } = error;
805
+ if (responseIndex === 0) throw new SmtpResponseError(`MAIL FROM failed: ${response.message}`, response.code, "MAIL FROM", response.message);
806
+ const recipient = message.envelope.to[responseIndex - 1];
807
+ throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${response.message}`, response.code, "RCPT TO", response.message);
808
+ }
809
+ else {
810
+ mailResponse = await this.sendCommand(mailCommand, signal);
811
+ recipientResponses = [];
812
+ }
734
813
  if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
735
814
  const rejectedRecipients = [];
736
- for (const recipient of message.envelope.to) {
815
+ for (const [index, recipient] of message.envelope.to.entries()) {
737
816
  signal?.throwIfAborted();
738
- const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
817
+ const rcptResponse = pipelining ? recipientResponses[index] : await this.sendCommand(recipientCommands[index], signal);
739
818
  if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
740
819
  if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
741
820
  recipient,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.6.0-dev.271",
3
+ "version": "0.6.0-dev.273",
4
4
  "description": "SMTP transport for Upyo email library",
5
5
  "keywords": [
6
6
  "email",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "sideEffects": false,
55
55
  "peerDependencies": {
56
- "@upyo/core": "0.6.0-dev.271+8deab0ad"
56
+ "@upyo/core": "0.6.0-dev.273+cd67e84f"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",