@upyo/smtp 0.6.0-dev.287 → 0.6.0-dev.289

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
@@ -27,6 +27,7 @@ Features
27
27
  - File attachments (regular and inline)
28
28
  - Multiple recipients (To, CC, BCC)
29
29
  - SMTP PIPELINING for faster multi-recipient delivery
30
+ - SMTP SIZE declaration and advertised-limit checks
30
31
  - Custom headers
31
32
  - Priority levels
32
33
  - Comprehensive testing utilities
@@ -167,6 +168,20 @@ token across pooled connections. See the
167
168
  [oauth-guide]: https://upyo.org/transports/smtp#oauth-2-0-authentication
168
169
 
169
170
 
171
+ Message size limits
172
+ -------------------
173
+
174
+ When the server advertises the `SIZE` extension, the transport declares the
175
+ encoded message size on `MAIL FROM`. If the server also advertises a fixed
176
+ maximum, an oversized message produces a failed receipt before Upyo sends the
177
+ envelope or uploads the message. Bare `SIZE` and `SIZE 0` advertisements do not
178
+ set a fixed maximum. This behavior is automatic and needs no configuration.
179
+
180
+ See [RFC 1870] for the SMTP Message Size Declaration extension.
181
+
182
+ [RFC 1870]: https://www.rfc-editor.org/rfc/rfc1870
183
+
184
+
170
185
  DKIM signing
171
186
  ------------
172
187
 
package/dist/index.cjs CHANGED
@@ -22,9 +22,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
22
 
23
23
  //#endregion
24
24
  const __upyo_core = __toESM(require("@upyo/core"));
25
+ const node_buffer = __toESM(require("node:buffer"));
25
26
  const node_net = __toESM(require("node:net"));
26
27
  const node_tls = __toESM(require("node:tls"));
27
- const node_buffer = __toESM(require("node:buffer"));
28
28
 
29
29
  //#region src/config.ts
30
30
  /**
@@ -361,6 +361,33 @@ var OAuth2TokenManager = class {
361
361
 
362
362
  //#endregion
363
363
  //#region src/smtp-connection.ts
364
+ /**
365
+ * Error thrown when a message exceeds the fixed limit advertised through the
366
+ * SMTP SIZE extension.
367
+ *
368
+ * The check happens before `MAIL FROM`, so the SMTP connection remains usable
369
+ * for another message.
370
+ *
371
+ * @since 0.6.0
372
+ */
373
+ var SmtpMessageSizeError = class extends RangeError {
374
+ /** The encoded message size in octets. */
375
+ actualSize;
376
+ /** The fixed maximum advertised by the SMTP server. */
377
+ maximumSize;
378
+ /**
379
+ * Creates an SMTP message-size error.
380
+ *
381
+ * @param actualSize The encoded message size in octets.
382
+ * @param maximumSize The fixed maximum advertised by the SMTP server.
383
+ */
384
+ constructor(actualSize, maximumSize) {
385
+ super(`Message size ${actualSize} octets exceeds the server's maximum of ${maximumSize} octets.`);
386
+ this.name = "SmtpMessageSizeError";
387
+ this.actualSize = actualSize;
388
+ this.maximumSize = maximumSize;
389
+ }
390
+ };
364
391
  var SmtpPipelineTerminatedError = class extends Error {
365
392
  responseIndex;
366
393
  response;
@@ -379,6 +406,24 @@ const MAX_COMMAND_LINE_LENGTH = 512;
379
406
  /** The length of the CRLF terminator appended to every command. */
380
407
  const CRLF_LENGTH = 2;
381
408
  /**
409
+ * Finds the RFC 1870 SIZE extension and its optional fixed maximum.
410
+ *
411
+ * A zero maximum and an omitted maximum both mean that no fixed limit is in
412
+ * force. A malformed parameter does not prevent use of the extension, but it
413
+ * cannot be used as a local limit.
414
+ *
415
+ * @param capabilities The extension lines returned by EHLO.
416
+ * @returns The SIZE capability, or `null` when it was not advertised.
417
+ */
418
+ function parseSizeCapability(capabilities) {
419
+ const capability = capabilities.find((value) => /^SIZE(?:[ \t]|$)/i.test(value));
420
+ if (capability == null) return null;
421
+ const match = /^SIZE(?:[ \t]+([0-9]+))?[ \t]*$/i.exec(capability);
422
+ if (match?.[1] == null) return { maximum: null };
423
+ const maximum = BigInt(match[1]);
424
+ return { maximum: maximum === 0n ? null : maximum };
425
+ }
426
+ /**
382
427
  * How long, in milliseconds, to wait for the graceful `QUIT` to flush during
383
428
  * teardown before giving up, so an unresponsive server cannot block shutdown
384
429
  * for the full socket timeout.
@@ -815,7 +860,15 @@ var SmtpConnection = class {
815
860
  throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
816
861
  }
817
862
  async sendMessage(message, signal) {
818
- const mailCommand = `MAIL FROM:<${message.envelope.from}>`;
863
+ signal?.throwIfAborted();
864
+ const sizeCapability = parseSizeCapability(this.capabilities);
865
+ let sizeParameter = "";
866
+ if (sizeCapability != null) {
867
+ const messageSize = node_buffer.Buffer.byteLength(message.raw, "utf8") + CRLF_LENGTH;
868
+ if (sizeCapability.maximum != null && BigInt(messageSize) > sizeCapability.maximum) throw new SmtpMessageSizeError(messageSize, sizeCapability.maximum);
869
+ sizeParameter = ` SIZE=${messageSize}`;
870
+ }
871
+ const mailCommand = `MAIL FROM:<${message.envelope.from}>${sizeParameter}`;
819
872
  const recipientCommands = message.envelope.to.map((recipient) => `RCPT TO:<${recipient}>`);
820
873
  const pipelining = this.capabilities.some((capability) => /^PIPELINING(?:\s|$)/i.test(capability));
821
874
  let mailResponse;
@@ -1581,7 +1634,8 @@ var SmtpTransport = class {
1581
1634
  rejectedRecipients: result.rejectedRecipients
1582
1635
  };
1583
1636
  } catch (error) {
1584
- if (connection != null) await this.discardConnection(connection);
1637
+ if (connection != null) if (error instanceof SmtpMessageSizeError) await this.returnConnection(connection);
1638
+ else await this.discardConnection(connection);
1585
1639
  options?.signal?.throwIfAborted();
1586
1640
  return createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1587
1641
  }
@@ -1651,7 +1705,7 @@ var SmtpTransport = class {
1651
1705
  };
1652
1706
  } catch (error) {
1653
1707
  options?.signal?.throwIfAborted();
1654
- connectionValid = false;
1708
+ if (!(error instanceof SmtpMessageSizeError)) connectionValid = false;
1655
1709
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1656
1710
  }
1657
1711
  }
@@ -1673,7 +1727,7 @@ var SmtpTransport = class {
1673
1727
  };
1674
1728
  } catch (error) {
1675
1729
  options?.signal?.throwIfAborted();
1676
- connectionValid = false;
1730
+ if (!(error instanceof SmtpMessageSizeError)) connectionValid = false;
1677
1731
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1678
1732
  }
1679
1733
  }
@@ -1768,6 +1822,17 @@ var SmtpTransport = class {
1768
1822
  }
1769
1823
  };
1770
1824
  function createSmtpFailure(message, error) {
1825
+ if (error instanceof SmtpMessageSizeError) return (0, __upyo_core.createFailedReceipt)(message, {
1826
+ provider: "smtp",
1827
+ code: "smtp.message-size-exceeded",
1828
+ category: "rejected",
1829
+ retryable: false,
1830
+ attempts: 1,
1831
+ providerDetails: {
1832
+ actualSize: error.actualSize,
1833
+ maximumSize: error.maximumSize.toString()
1834
+ }
1835
+ });
1771
1836
  if (error instanceof SmtpResponseError) {
1772
1837
  const classification = classifySmtpReply(error.code);
1773
1838
  return (0, __upyo_core.createFailedReceipt)(message, {
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createFailedReceipt } from "@upyo/core";
2
+ import { Buffer } from "node:buffer";
2
3
  import { Socket } from "node:net";
3
4
  import { TLSSocket, connect } from "node:tls";
4
- import { Buffer } from "node:buffer";
5
5
 
6
6
  //#region src/config.ts
7
7
  /**
@@ -338,6 +338,33 @@ var OAuth2TokenManager = class {
338
338
 
339
339
  //#endregion
340
340
  //#region src/smtp-connection.ts
341
+ /**
342
+ * Error thrown when a message exceeds the fixed limit advertised through the
343
+ * SMTP SIZE extension.
344
+ *
345
+ * The check happens before `MAIL FROM`, so the SMTP connection remains usable
346
+ * for another message.
347
+ *
348
+ * @since 0.6.0
349
+ */
350
+ var SmtpMessageSizeError = class extends RangeError {
351
+ /** The encoded message size in octets. */
352
+ actualSize;
353
+ /** The fixed maximum advertised by the SMTP server. */
354
+ maximumSize;
355
+ /**
356
+ * Creates an SMTP message-size error.
357
+ *
358
+ * @param actualSize The encoded message size in octets.
359
+ * @param maximumSize The fixed maximum advertised by the SMTP server.
360
+ */
361
+ constructor(actualSize, maximumSize) {
362
+ super(`Message size ${actualSize} octets exceeds the server's maximum of ${maximumSize} octets.`);
363
+ this.name = "SmtpMessageSizeError";
364
+ this.actualSize = actualSize;
365
+ this.maximumSize = maximumSize;
366
+ }
367
+ };
341
368
  var SmtpPipelineTerminatedError = class extends Error {
342
369
  responseIndex;
343
370
  response;
@@ -356,6 +383,24 @@ const MAX_COMMAND_LINE_LENGTH = 512;
356
383
  /** The length of the CRLF terminator appended to every command. */
357
384
  const CRLF_LENGTH = 2;
358
385
  /**
386
+ * Finds the RFC 1870 SIZE extension and its optional fixed maximum.
387
+ *
388
+ * A zero maximum and an omitted maximum both mean that no fixed limit is in
389
+ * force. A malformed parameter does not prevent use of the extension, but it
390
+ * cannot be used as a local limit.
391
+ *
392
+ * @param capabilities The extension lines returned by EHLO.
393
+ * @returns The SIZE capability, or `null` when it was not advertised.
394
+ */
395
+ function parseSizeCapability(capabilities) {
396
+ const capability = capabilities.find((value) => /^SIZE(?:[ \t]|$)/i.test(value));
397
+ if (capability == null) return null;
398
+ const match = /^SIZE(?:[ \t]+([0-9]+))?[ \t]*$/i.exec(capability);
399
+ if (match?.[1] == null) return { maximum: null };
400
+ const maximum = BigInt(match[1]);
401
+ return { maximum: maximum === 0n ? null : maximum };
402
+ }
403
+ /**
359
404
  * How long, in milliseconds, to wait for the graceful `QUIT` to flush during
360
405
  * teardown before giving up, so an unresponsive server cannot block shutdown
361
406
  * for the full socket timeout.
@@ -792,7 +837,15 @@ var SmtpConnection = class {
792
837
  throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
793
838
  }
794
839
  async sendMessage(message, signal) {
795
- const mailCommand = `MAIL FROM:<${message.envelope.from}>`;
840
+ signal?.throwIfAborted();
841
+ const sizeCapability = parseSizeCapability(this.capabilities);
842
+ let sizeParameter = "";
843
+ if (sizeCapability != null) {
844
+ const messageSize = Buffer.byteLength(message.raw, "utf8") + CRLF_LENGTH;
845
+ if (sizeCapability.maximum != null && BigInt(messageSize) > sizeCapability.maximum) throw new SmtpMessageSizeError(messageSize, sizeCapability.maximum);
846
+ sizeParameter = ` SIZE=${messageSize}`;
847
+ }
848
+ const mailCommand = `MAIL FROM:<${message.envelope.from}>${sizeParameter}`;
796
849
  const recipientCommands = message.envelope.to.map((recipient) => `RCPT TO:<${recipient}>`);
797
850
  const pipelining = this.capabilities.some((capability) => /^PIPELINING(?:\s|$)/i.test(capability));
798
851
  let mailResponse;
@@ -1558,7 +1611,8 @@ var SmtpTransport = class {
1558
1611
  rejectedRecipients: result.rejectedRecipients
1559
1612
  };
1560
1613
  } catch (error) {
1561
- if (connection != null) await this.discardConnection(connection);
1614
+ if (connection != null) if (error instanceof SmtpMessageSizeError) await this.returnConnection(connection);
1615
+ else await this.discardConnection(connection);
1562
1616
  options?.signal?.throwIfAborted();
1563
1617
  return createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1564
1618
  }
@@ -1628,7 +1682,7 @@ var SmtpTransport = class {
1628
1682
  };
1629
1683
  } catch (error) {
1630
1684
  options?.signal?.throwIfAborted();
1631
- connectionValid = false;
1685
+ if (!(error instanceof SmtpMessageSizeError)) connectionValid = false;
1632
1686
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1633
1687
  }
1634
1688
  }
@@ -1650,7 +1704,7 @@ var SmtpTransport = class {
1650
1704
  };
1651
1705
  } catch (error) {
1652
1706
  options?.signal?.throwIfAborted();
1653
- connectionValid = false;
1707
+ if (!(error instanceof SmtpMessageSizeError)) connectionValid = false;
1654
1708
  yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
1655
1709
  }
1656
1710
  }
@@ -1745,6 +1799,17 @@ var SmtpTransport = class {
1745
1799
  }
1746
1800
  };
1747
1801
  function createSmtpFailure(message, error) {
1802
+ if (error instanceof SmtpMessageSizeError) return createFailedReceipt(message, {
1803
+ provider: "smtp",
1804
+ code: "smtp.message-size-exceeded",
1805
+ category: "rejected",
1806
+ retryable: false,
1807
+ attempts: 1,
1808
+ providerDetails: {
1809
+ actualSize: error.actualSize,
1810
+ maximumSize: error.maximumSize.toString()
1811
+ }
1812
+ });
1748
1813
  if (error instanceof SmtpResponseError) {
1749
1814
  const classification = classifySmtpReply(error.code);
1750
1815
  return createFailedReceipt(message, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.6.0-dev.287",
3
+ "version": "0.6.0-dev.289",
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.287+45372d17"
56
+ "@upyo/core": "0.6.0-dev.289+b1231421"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",