@bumail/smtp 0.2.0 → 0.3.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/docs/guide.md CHANGED
@@ -295,14 +295,19 @@ export interface Path {
295
295
  ```
296
296
 
297
297
  A source route (`<@a.example:b@c.example>`, RFC 5321 Appendix C) is dropped:
298
- the path is `b@c.example`.
298
+ the path is `b@c.example`. It is taken only as `@domain(,@domain)*:`, each
299
+ hop a valid domain or address literal; any other route — an empty hop, a
300
+ hop without its `@`, a hop that is not a domain — is a `501 5.5.4 Syntax`.
299
301
 
300
302
  `local` is kept as written, quotes included. `<"v@x.example"@example.com>`
301
303
  and `<v%x.example@example.com>` are local parts of `example.com`, and
302
304
  passed the relay check as such: code that delivers must take the domain
303
305
  after the last `@` — `path.domain` — and never split `address` on its
304
- first `@`. C1 controls, Unicode format characters (zero-width, bidi, BOM)
305
- and U+2028/2029 are refused in a path, even under SMTPUTF8.
306
+ first `@`. C0 controls (CR, LF, NUL, tab…), DEL and `>`, C1 controls,
307
+ Unicode format characters (zero-width, bidi, BOM), U+2028/2029 and lone
308
+ surrogates are refused anywhere in a path — the route, the local part
309
+ quoted or not, the domain — even under SMTPUTF8. An IPv4 address literal
310
+ takes octets up to 255: `[999.1.1.1]` is not one.
306
311
 
307
312
  ## Session.data
308
313
 
@@ -878,8 +883,17 @@ server decides — the idle `timeout`, `maxErrors`, three failed AUTH
878
883
  attempts, a refusal from `onConnect`, a local error — it writes its reply
879
884
  and closes at once: the connection is counted out of `connections` then and
880
885
  there, and replies the client never read are dropped, the connection reset
881
- if any were still waiting. So a client that pipelines commands and stops
882
- reading cannot keep a slot of `maxConnections` past its `timeout`.
886
+ if any were still waiting. When the server had stopped reading a client
887
+ that sent more than it could take, any hang-up, `QUIT`'s included, reads
888
+ again first, dropping whatever comes, and half-closes once the client's
889
+ input stops for 20 ms: a half-close does not complete over input left
890
+ unread, and on Linux a close over unread input is a reset that loses the
891
+ last reply. A client whose input went quiet for 20 ms within 500 ms of
892
+ the hang-up then still reads the last reply and the end. It waits 500 ms
893
+ at most: input not quiet for 20 ms by then, a client still sending or one
894
+ that stopped in the last 20 ms, is reset, so its slot is free by then too. So a client that
895
+ pipelines commands and stops reading cannot keep a slot of
896
+ `maxConnections` past its `timeout`.
883
897
 
884
898
  Every hang-up is bounded, on a clear socket, on implicit TLS and after
885
899
  STARTTLS alike. When the server decides, it never waits for the client to
@@ -955,8 +969,27 @@ await sendMail(message, { domain: 'example.org', resolver: nodeResolver(), helo:
955
969
 
956
970
  `from` is the envelope's reverse-path — `''` for the null sender of a
957
971
  bounce — and `to` one recipient or more. Both are checked as RFC 5321 paths
958
- before anything is sent: an address holding a CR, an LF or a `>` is refused
959
- with `INVALID_OPTION`, so it cannot inject a command.
972
+ before anything is sent, before the client even connects: an address
973
+ holding a CR, an LF, a NUL, any other control character or a `>`, in any
974
+ part, is refused with `INVALID_OPTION`, so it cannot inject a command. A
975
+ source route (`@a.example,@b.example:x@c.example`) is refused too, valid
976
+ or not: RFC 5321 §4.1.1.3 says a client should not send one, and the
977
+ address is written as it is given.
978
+
979
+ Code that keeps addresses to send to later — a queue — checks
980
+ each one when it takes it with `isMailbox`, which says yes to exactly the
981
+ addresses `sendMail` takes — it checks with the same predicate: an RFC 5321
982
+ Mailbox with no source route, no control character (C0, DEL or C1), no
983
+ `>`, no U+2028 or U+2029, no Unicode format character (`\p{Cf}`), no
984
+ lone surrogate and no IPv4 literal octet above 255 — so the refusal
985
+ comes back to whoever gave it:
986
+
987
+ ```ts
988
+ import { isMailbox } from '@bumail/smtp/client';
989
+
990
+ isMailbox('mary@example.net'); // true
991
+ isMailbox('a,b@example.net'); // false: sendMail would refuse it
992
+ ```
960
993
 
961
994
  One call is one destination. With `{ domain }`, every recipient should be
962
995
  at that domain: group them by domain first, one `sendMail` each.
@@ -1291,7 +1324,11 @@ console.log(chunk.done, reader.bareLineBreaks);
1291
1324
 
1292
1325
  - `parsePathCommand` and `parsePath` return `undefined` when the syntax is
1293
1326
  wrong. `parsePath` takes UTF-8 addresses; whether a session may use one
1294
- is SMTPUTF8's question.
1327
+ is SMTPUTF8's question. A control character or a `>` anywhere is wrong.
1328
+ Its third argument says what to do with a source route: `'discard'`, the
1329
+ default and what a server does, drops a valid `@domain(,@domain)*:`;
1330
+ `'refuse'`, what a client does, returns `undefined` for any route:
1331
+ `parsePath('<@a,@b:x@c.com>', false, 'refuse')` is `undefined`.
1295
1332
  - `formatReply` replaces a CR or LF in the text with a space, so a reply
1296
1333
  text cannot start a second reply.
1297
1334
  - `DataReader.write(chunk)` returns the unstuffed bytes, whether the
@@ -89,6 +89,7 @@ A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
89
89
  **Sending mail: options**
90
90
 
91
91
  - [`SmtpError: sendMail(): "…" is not an address (local@domain)`](#smtperror-sendmail--is-not-an-address-localdomain)
92
+ - [`SmtpError: sendMail(): "…" holds a source route (@host:), which RFC 5321 says a client should not send: pass "…" alone`](#smtperror-sendmail--holds-a-source-route-host-which-rfc-5321-says-a-client-should-not-send-pass--alone)
92
93
  - [`SmtpError: sendMail(): to must name one recipient or more`](#smtperror-sendmail-to-must-name-one-recipient-or-more)
93
94
  - [`SmtpError: sendMail(): helo "…" is not a host name`](#smtperror-sendmail-helo--is-not-a-host-name)
94
95
  - [`SmtpError: sendMail(): helo is required for delivery by MX: pass your server's public name, such as helo: 'mail.example.com'`](#smtperror-sendmail-helo-is-required-for-delivery-by-mx-pass-your-servers-public-name-such-as-helo-mailexamplecom)
@@ -737,7 +738,10 @@ base64 response to the challenge — see
737
738
  ### `501 Syntax: EHLO hostname`
738
739
 
739
740
  **When**: `EHLO` with no argument, or with one that is neither a domain nor
740
- an address literal — an underscore, a space, a bare IP without brackets.
741
+ an address literal — an underscore, a space, a bare IP without brackets,
742
+ an IPv4 literal with an octet above 255 (`[999.1.1.1]`), an IPv6 one
743
+ without its `IPv6:` tag (`[2001:db8::1]`). The tag may be in any case:
744
+ `[ipv6:2001:db8::1]` is accepted.
741
745
  `HELO` gets `501 Syntax: HELO hostname`. Before an EHLO was accepted the
742
746
  reply has no enhanced code; after one, it is
743
747
  `501 5.5.4 Syntax: EHLO hostname`. It counts toward `maxErrors`.
@@ -799,7 +803,10 @@ send `RSET` to start a transaction over.
799
803
 
800
804
  **When**: a command whose argument is wrong: `MAIL FROM:<address>` without
801
805
  the angle brackets or the colon, a path with a space, a control or an
802
- invisible character (C1, zero-width, bidi, U+2028), `DATA` or `STARTTLS`
806
+ invisible character (NUL, ESC, DEL, C1, zero-width, bidi, U+2028), a source
807
+ route that is not `@domain(,@domain)*:` (`<@a,b:x@foo.com>`,
808
+ `<@a..b:x@foo.com>`; a valid one is accepted and dropped), an IPv4
809
+ address literal with an octet above 255 (`<a@[999.1.1.1]>`), `DATA` or `STARTTLS`
803
810
  with an argument, `AUTH` with more than a mechanism and a response,
804
811
  `SIZE=` that is not a number. The text after `Syntax:` gives the expected
805
812
  form. Each counts toward `maxErrors`.
@@ -1415,8 +1422,15 @@ needs it at `0`.
1415
1422
  ### `SmtpError: sendMail(): "…" is not an address (local@domain)`
1416
1423
 
1417
1424
  **When**: `from` or a recipient in `to` is not an RFC 5321 path: no `@`, a
1418
- display name (`Bob <b@example.org>`), angle brackets, a space, or a CR or
1419
- LF. For `from` the message ends `(local@domain, or '' for a bounce)`.
1425
+ display name (`Bob <b@example.org>`), angle brackets, a space outside
1426
+ quotes (`a b@c.com`; the quoted `"a b"@c.com` is an address), or a CR, an
1427
+ LF, a NUL, any other control character, a `>`, a C1 control, a Unicode
1428
+ format character (zero-width, bidi, BOM), U+2028/2029 or a lone surrogate
1429
+ (`a\uD800@c.com`) in any part — the local part, quoted or not, the
1430
+ domain, or a source route (`@x\r\nRSET\r\nNOOP:a@c.com`) — or an IPv4
1431
+ address literal with an octet above 255 (`a@[999.1.1.1]`). A source route whose hops are not each
1432
+ `@domain` lands here too. For `from` the message ends `(local@domain, or ''
1433
+ for a bounce)`. Nothing is sent: the check runs before the client connects.
1420
1434
 
1421
1435
  **Why**: the address goes into `MAIL FROM:<…>` or `RCPT TO:<…>` as it is;
1422
1436
  anything else could break the command or inject another.
@@ -1429,6 +1443,25 @@ import { sendMail } from '@bumail/smtp/client';
1429
1443
  await sendMail('Subject: hi\r\n\r\nhello\r\n', { host: 'relay.example.net', from: '', to: ['b@example.org'] });
1430
1444
  ```
1431
1445
 
1446
+ ### `SmtpError: sendMail(): "…" holds a source route (@host:), which RFC 5321 says a client should not send: pass "…" alone`
1447
+
1448
+ **When**: `from` or a recipient in `to` starts with a source route, such as
1449
+ `@a.example,@b.example:x@c.example` (RFC 5321 Appendix C). The second `"…"`
1450
+ is the address without it.
1451
+
1452
+ **Why**: RFC 5321 §4.1.1.3 says a client should not send a source route,
1453
+ and the client writes the address as it is given. A server accepts and
1454
+ drops one, so it never routed anything; refusing it keeps the envelope to
1455
+ the plain `local@domain` the client checked.
1456
+
1457
+ **Fix**: pass the address the message names, after the colon:
1458
+
1459
+ ```ts
1460
+ import { sendMail } from '@bumail/smtp/client';
1461
+
1462
+ await sendMail('Subject: hi\r\n\r\nhello\r\n', { host: 'relay.example.net', from: 'a@example.com', to: 'x@c.example' });
1463
+ ```
1464
+
1432
1465
  ### `SmtpError: sendMail(): to must name one recipient or more`
1433
1466
 
1434
1467
  **When**: `to` is an empty array, or neither a string nor an array.
@@ -1440,7 +1473,10 @@ await sendMail('Subject: hi\r\n\r\nhello\r\n', { host: 'relay.example.net', from
1440
1473
  ### `SmtpError: sendMail(): helo "…" is not a host name`
1441
1474
 
1442
1475
  **When**: `helo` is not a host name nor an address literal such as
1443
- `[192.0.2.1]`.
1476
+ `[192.0.2.1]` or `[IPv6:2001:db8::1]`: an underscore, a space, a bare IP
1477
+ without brackets, an IPv4 literal with an octet above 255
1478
+ (`[999.1.1.1]`), an IPv6 one without its `IPv6:` tag. The tag may be in
1479
+ any case: `[ipv6:2001:db8::1]` is taken.
1444
1480
 
1445
1481
  **Why**: it is the argument of EHLO and HELO (RFC 5321 §4.1.1.1).
1446
1482
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bumail/smtp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "An SMTP server on Bun.listen — RFC 5321 with PIPELINING, SIZE, 8BITMIME, SMTPUTF8, STARTTLS and AUTH after TLS, policy hooks, and no open relay — and a client that delivers a message to a smarthost or by MX",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,9 +47,9 @@
47
47
  ]
48
48
  },
49
49
  "devDependencies": {
50
- "@bumail/auth": "^0.2.0",
50
+ "@bumail/auth": "^0.3.0",
51
51
  "@bumail/dns": "^0.1.1",
52
- "@bumail/store": "^0.2.0",
52
+ "@bumail/store": "^0.3.0",
53
53
  "@types/bun": "^1.4.2"
54
54
  },
55
55
  "peerDependencies": {