@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/README.md +2 -1
- package/dist/chunks/{index-2ybj2z1w.js → index-3423jxf9.js} +43 -10
- package/dist/chunks/{index-2ybj2z1w.js.map → index-3423jxf9.js.map} +3 -3
- package/dist/client/address.d.ts +14 -0
- package/dist/client/address.d.ts.map +1 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +18 -8
- package/dist/client/index.js.map +5 -4
- package/dist/client/settings.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +51 -3
- package/dist/index.js.map +5 -5
- package/dist/protocol/path.d.ts +24 -3
- package/dist/protocol/path.d.ts.map +1 -1
- package/dist/server/commands.d.ts.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/transport.d.ts +19 -0
- package/dist/server/transport.d.ts.map +1 -1
- package/docs/guide.md +45 -8
- package/docs/troubleshooting.md +41 -5
- package/package.json +3 -3
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 `@`.
|
|
305
|
-
|
|
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.
|
|
882
|
-
|
|
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
|
|
959
|
-
|
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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),
|
|
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
|
|
1419
|
-
|
|
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.
|
|
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.
|
|
50
|
+
"@bumail/auth": "^0.3.0",
|
|
51
51
|
"@bumail/dns": "^0.1.1",
|
|
52
|
-
"@bumail/store": "^0.
|
|
52
|
+
"@bumail/store": "^0.3.0",
|
|
53
53
|
"@types/bun": "^1.4.2"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|