@bumail/smtp 0.1.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/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1319 -0
- package/dist/index.js.map +29 -0
- package/dist/protocol/command.d.ts +20 -0
- package/dist/protocol/command.d.ts.map +1 -0
- package/dist/protocol/data.d.ts +24 -0
- package/dist/protocol/data.d.ts.map +1 -0
- package/dist/protocol/path.d.ts +20 -0
- package/dist/protocol/path.d.ts.map +1 -0
- package/dist/protocol/reply.d.ts +13 -0
- package/dist/protocol/reply.d.ts.map +1 -0
- package/dist/protocol/sasl.d.ts +16 -0
- package/dist/protocol/sasl.d.ts.map +1 -0
- package/dist/server/auth.d.ts +6 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/commands.d.ts +4 -0
- package/dist/server/commands.d.ts.map +1 -0
- package/dist/server/connection.d.ts +52 -0
- package/dist/server/connection.d.ts.map +1 -0
- package/dist/server/guard.d.ts +18 -0
- package/dist/server/guard.d.ts.map +1 -0
- package/dist/server/input.d.ts +31 -0
- package/dist/server/input.d.ts.map +1 -0
- package/dist/server/intake.d.ts +31 -0
- package/dist/server/intake.d.ts.map +1 -0
- package/dist/server/lines.d.ts +25 -0
- package/dist/server/lines.d.ts.map +1 -0
- package/dist/server/options.d.ts +151 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/received.d.ts +11 -0
- package/dist/server/received.d.ts.map +1 -0
- package/dist/server/refusals.d.ts +16 -0
- package/dist/server/refusals.d.ts.map +1 -0
- package/dist/server/server.d.ts +25 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/settings.d.ts +16 -0
- package/dist/server/settings.d.ts.map +1 -0
- package/dist/server/state.d.ts +24 -0
- package/dist/server/state.d.ts.map +1 -0
- package/dist/server/transaction.d.ts +6 -0
- package/dist/server/transaction.d.ts.map +1 -0
- package/dist/server/transport.d.ts +42 -0
- package/dist/server/transport.d.ts.map +1 -0
- package/docs/README.md +10 -0
- package/docs/guide.md +976 -0
- package/docs/roadmap.md +45 -0
- package/docs/troubleshooting.md +1308 -0
- package/package.json +55 -0
package/docs/guide.md
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
# Guide
|
|
2
|
+
|
|
3
|
+
How to run `@bumail/smtp` and what it does on the wire: the session and its
|
|
4
|
+
replies, the hooks, what a delivered message looks like, TLS, and the RFCs
|
|
5
|
+
behind each behaviour.
|
|
6
|
+
|
|
7
|
+
- [The smallest server](#the-smallest-server)
|
|
8
|
+
- [Options](#options)
|
|
9
|
+
- [The session, and what each reply means](#the-session-and-what-each-reply-means)
|
|
10
|
+
- [Hooks, and their order](#hooks-and-their-order)
|
|
11
|
+
- [Session.data](#sessiondata)
|
|
12
|
+
- [What onData receives](#what-ondata-receives)
|
|
13
|
+
- [Delivering into @bumail/store](#delivering-into-bumailstore)
|
|
14
|
+
- [Authentication](#authentication)
|
|
15
|
+
- [TLS](#tls)
|
|
16
|
+
- [Limits](#limits)
|
|
17
|
+
- [Protocol helpers](#protocol-helpers)
|
|
18
|
+
- [The RFCs implemented](#the-rfcs-implemented)
|
|
19
|
+
- [What is not implemented](#what-is-not-implemented)
|
|
20
|
+
|
|
21
|
+
## The smallest server
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
25
|
+
|
|
26
|
+
const server = createSmtpServer({
|
|
27
|
+
hostname: 'mx.example.com',
|
|
28
|
+
localDomains: ['example.com'],
|
|
29
|
+
async onData(message) {
|
|
30
|
+
const text = await new Response(message.content).text();
|
|
31
|
+
console.log(message.envelope.from, '→', message.envelope.to, text.length);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const { port } = await server.listen({ port: 2525, hostname: '127.0.0.1' });
|
|
36
|
+
console.log(`listening on ${port}`);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`onData` is the only hook you must give: it is where a message goes, as a
|
|
40
|
+
stream, while the client sends it. `listen` resolves once the port is bound; `port: 0` picks a free
|
|
41
|
+
one, and the returned `port` says which.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export function createSmtpServer(options: SmtpServerOptions): SmtpServer;
|
|
45
|
+
|
|
46
|
+
export interface SmtpServer {
|
|
47
|
+
/** Starts listening; resolves once the port is bound. Default hostname '0.0.0.0'. Once only. */
|
|
48
|
+
listen(options: { port: number; hostname?: string }): Promise<{ port: number; hostname: string }>;
|
|
49
|
+
/** Stops listening; `closeConnections` hangs up on every client too. */
|
|
50
|
+
stop(closeConnections?: boolean): void;
|
|
51
|
+
/** Open connections. */
|
|
52
|
+
readonly connections: number;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Options
|
|
57
|
+
|
|
58
|
+
| Option | Type | Default | Effect |
|
|
59
|
+
| --- | --- | --- | --- |
|
|
60
|
+
| `hostname` | `string` | required, no default | the server's name, in its greeting, its EHLO reply and its Received fields; letters, digits, dots and hyphens |
|
|
61
|
+
| `localDomains` | `string[]` or `(domain) => boolean \| Promise<boolean>` | required | the domains it receives mail for; any other recipient is relaying, refused without AUTH |
|
|
62
|
+
| `mode` | `'mx' \| 'submission'` | `'mx'` | `mx` takes mail for `localDomains` from anyone; `submission` takes mail only from authenticated users (RFC 6409) |
|
|
63
|
+
| `tls` | `{ key, cert }` | none | turns on STARTTLS, or implicit TLS with `implicitTls` |
|
|
64
|
+
| `implicitTls` | `boolean` | `false` | TLS from the first byte (RFC 8314, port 465); needs `tls` |
|
|
65
|
+
| `authenticate` | `(credentials, session) => boolean \| Promise<boolean>` | none | turns on AUTH PLAIN and LOGIN, over TLS only, so it needs `tls`; required by `submission` |
|
|
66
|
+
| `maxMessageSize` | `number` | 25 MiB | bytes; announced with SIZE |
|
|
67
|
+
| `maxRecipients` | `number` | `100` | recipients per message |
|
|
68
|
+
| `maxConnections` | `number` | `1000` | open connections at once |
|
|
69
|
+
| `maxErrors` | `number` | `10` | failed commands before the server hangs up |
|
|
70
|
+
| `timeout` | `number` | `300` | seconds since the client's last byte, or since the 220, before the server hangs up |
|
|
71
|
+
| `hookTimeout` | `number` | `60` | seconds a hook, `authenticate` or `localDomains` has to settle, and `onData` to read on; past it, `451 4.3.0` |
|
|
72
|
+
| `greetingDelay` | `number` | `0` | seconds the server waits, once `onConnect` accepted, before its 220; a client that talks meanwhile gets `554` and is hung up on. Fractions are allowed |
|
|
73
|
+
| `onConnect`, `onMailFrom`, `onRcptTo` | hooks | none | see [Hooks](#hooks-and-their-order) |
|
|
74
|
+
| `onData` | hook | required | receives each message, as a stream |
|
|
75
|
+
| `onError` | `(error, session) => void` | none | told of what went wrong in your code: a hook, `authenticate` or `localDomains` that threw or timed out, a hook reply that is not a refusal |
|
|
76
|
+
|
|
77
|
+
`localDomains` is matched against the whole domain, without case:
|
|
78
|
+
`example.com` takes `b@EXAMPLE.COM`, not `b@sub.example.com` nor
|
|
79
|
+
`b@evilexample.com`. A function decides for itself, and is given the domain
|
|
80
|
+
in lower case; if it throws or does not settle within `hookTimeout`, that
|
|
81
|
+
recipient gets `451 4.3.0` — never taken as local:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
85
|
+
|
|
86
|
+
const hosted = new Set(['example.com', 'example.org']);
|
|
87
|
+
|
|
88
|
+
const server = createSmtpServer({
|
|
89
|
+
hostname: 'mx.example.com',
|
|
90
|
+
localDomains: async (domain) => hosted.has(domain) || domain.endsWith('.example.com'),
|
|
91
|
+
onData: async (message) => {
|
|
92
|
+
await new Response(message.content).bytes();
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await server.listen({ port: 25 });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`createSmtpServer` checks its options once and throws an `SmtpError` with
|
|
100
|
+
`code: 'INVALID_OPTION'` for:
|
|
101
|
+
|
|
102
|
+
| Message | Cause |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `createSmtpServer(): "<hostname>" is not a host name` | `hostname` is missing (`"undefined" is not a host name`), or has a character other than a letter, a digit, `.` or `-` |
|
|
105
|
+
| `createSmtpServer(): onData must be a function: it is where messages go` | no `onData` |
|
|
106
|
+
| `createSmtpServer(): implicitTls needs tls: { key, cert }` | `implicitTls: true` without `tls` |
|
|
107
|
+
| `createSmtpServer(): submission takes mail only from authenticated users, so it needs authenticate` | `mode: 'submission'` without `authenticate` |
|
|
108
|
+
| `createSmtpServer(): authenticate needs tls: { key, cert }, since AUTH is offered only once encrypted` | `authenticate` without `tls` |
|
|
109
|
+
| `createSmtpServer(): localDomains must be an array of domains or a function` | `localDomains` missing, a string, or an array holding something else |
|
|
110
|
+
| `createSmtpServer(): <limit> must be a positive integer, not <value>` | a limit or `hookTimeout` that is `0`, negative, fractional or `NaN` |
|
|
111
|
+
| `createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not <value>` | `greetingDelay` negative, `NaN` or `Infinity` |
|
|
112
|
+
| `createSmtpServer(): greetingDelay (<n> s) must be shorter than timeout (<n> s), or every client times out before the greeting` | `greetingDelay` as long as `timeout`, or longer |
|
|
113
|
+
|
|
114
|
+
`listen` a second time throws an `SmtpError` with `code:
|
|
115
|
+
'ALREADY_LISTENING'`: create another server for another port.
|
|
116
|
+
|
|
117
|
+
What to do about each is in [Troubleshooting](troubleshooting.md).
|
|
118
|
+
|
|
119
|
+
## The session, and what each reply means
|
|
120
|
+
|
|
121
|
+
A session as RFC 5321 Appendix D.1 prints it, with an `onRcptTo` that
|
|
122
|
+
refuses `Green`:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
S: 220 foo.com ESMTP ready
|
|
126
|
+
C: EHLO bar.com
|
|
127
|
+
S: 250-foo.com greets bar.com
|
|
128
|
+
S: 250-PIPELINING
|
|
129
|
+
S: 250-SIZE 26214400
|
|
130
|
+
S: 250-8BITMIME
|
|
131
|
+
S: 250-SMTPUTF8
|
|
132
|
+
S: 250 ENHANCEDSTATUSCODES
|
|
133
|
+
C: MAIL FROM:<Smith@bar.com>
|
|
134
|
+
S: 250 2.1.0 OK
|
|
135
|
+
C: RCPT TO:<Jones@foo.com>
|
|
136
|
+
S: 250 2.1.5 OK
|
|
137
|
+
C: RCPT TO:<Green@foo.com>
|
|
138
|
+
S: 550 5.1.1 No such user here
|
|
139
|
+
C: RCPT TO:<Brown@foo.com>
|
|
140
|
+
S: 250 2.1.5 OK
|
|
141
|
+
C: DATA
|
|
142
|
+
S: 354 End data with <CR><LF>.<CR><LF>
|
|
143
|
+
C: Blah blah blah...
|
|
144
|
+
C: ...etc. etc. etc.
|
|
145
|
+
C: .
|
|
146
|
+
S: 250 2.0.0 OK queued as 3f9c0a1b2c3d4e5f6a7b
|
|
147
|
+
C: QUIT
|
|
148
|
+
S: 221 2.0.0 foo.com closing connection
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The EHLO reply adds `STARTTLS` when `tls` is set and the connection is
|
|
152
|
+
clear, and `AUTH PLAIN LOGIN` when `authenticate` is set, the connection is
|
|
153
|
+
encrypted, and the session has not authenticated yet.
|
|
154
|
+
|
|
155
|
+
Enhanced status codes (`2.1.0`, RFC 3463) are written only after EHLO; a
|
|
156
|
+
client that said HELO gets `250 OK`. Replies come in the order the commands
|
|
157
|
+
were sent, even when a client pipelines a whole transaction in one write.
|
|
158
|
+
|
|
159
|
+
Every reply the server sends of its own:
|
|
160
|
+
|
|
161
|
+
| Reply | When |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `220 <hostname> ESMTP ready` | the greeting |
|
|
164
|
+
| `250 <hostname> greets <name>` | EHLO or HELO accepted; either one also ends a transaction in progress |
|
|
165
|
+
| `501 Syntax: EHLO hostname` / `501 Syntax: HELO hostname` | the EHLO or HELO argument is missing, or is not a domain or an address literal; no enhanced code before an EHLO was accepted, `501 5.5.4 Syntax: EHLO hostname` after one |
|
|
166
|
+
| `250 2.1.0 OK` | MAIL FROM accepted |
|
|
167
|
+
| `250 2.1.5 OK` | RCPT TO accepted |
|
|
168
|
+
| `354 End data with <CR><LF>.<CR><LF>` | DATA: send the message |
|
|
169
|
+
| `250 2.0.0 OK queued as <id>` | the message was taken: `onData` read its content stream to the clean end and resolved without refusing |
|
|
170
|
+
| `250 2.0.0 OK` | RSET (the transaction is forgotten) or NOOP |
|
|
171
|
+
| `252 2.5.0 Cannot VRFY user; send the message and it will be tried` | VRFY: no account is confirmed nor denied (RFC 5321 §3.5.3) |
|
|
172
|
+
| `214 2.0.0 See RFC 5321` | HELP |
|
|
173
|
+
| `221 2.0.0 <hostname> closing connection` | QUIT |
|
|
174
|
+
| `503 Send EHLO first` | MAIL or AUTH before EHLO (or after STARTTLS); no enhanced code, since no EHLO is in effect |
|
|
175
|
+
| `503 5.5.1 Nested MAIL command` | a second MAIL in one transaction |
|
|
176
|
+
| `503 5.5.1 Send MAIL first` | RCPT or DATA before MAIL |
|
|
177
|
+
| `554 5.5.1 No valid recipients` | DATA with no recipient accepted; what the client pipelined behind it in the same write is dropped, not run as commands |
|
|
178
|
+
| `501 5.5.4 Syntax: MAIL FROM:<address>` / `RCPT TO:<address>` / `DATA` | the command's syntax is wrong |
|
|
179
|
+
| `555 5.5.4 <PARAM> is not supported` | an ESMTP parameter other than SIZE, BODY, SMTPUTF8 and AUTH on MAIL, or any parameter on RCPT |
|
|
180
|
+
| `555 5.5.4 <PARAM> needs EHLO` | a parameter after HELO |
|
|
181
|
+
| `501 5.5.4 Syntax: SIZE=<bytes>` | SIZE is not a number |
|
|
182
|
+
| `501 5.5.4 BODY is 7BIT or 8BITMIME` | another BODY value |
|
|
183
|
+
| `553 5.6.7 A non-ASCII address needs SMTPUTF8` | a UTF-8 address in a transaction that did not ask for SMTPUTF8 |
|
|
184
|
+
| `530 5.7.0 Authentication required` | MAIL on a `submission` server before AUTH |
|
|
185
|
+
| `554 5.7.1 Relay access denied` | a recipient outside `localDomains`, and the session did not authenticate |
|
|
186
|
+
| `452 4.5.3 Too many recipients` | past `maxRecipients`; the recipients already taken stand |
|
|
187
|
+
| `552 5.3.4 Message too big for system` | SIZE= over `maxMessageSize` at MAIL, or a message over it after DATA |
|
|
188
|
+
| `550 5.6.11 Bare CR or LF is not allowed in a message` | the message holds a CR or LF that is not part of a CRLF |
|
|
189
|
+
| `451 4.3.0 Local error in processing` | a hook threw, timed out or answered a reply under 400; `localDomains` threw or timed out; `onData` stopped reading the message, or answered without reading it to its end |
|
|
190
|
+
| `554 <hostname> Talked before the greeting` | the client sent something before the 220 — during `onConnect` or `greetingDelay` (RFC 5321 §4.3.1); the server hangs up |
|
|
191
|
+
| `500 5.5.2 Command unrecognized` | an unknown verb — EXPN, BDAT and the rest |
|
|
192
|
+
| `500 5.5.6 Line too long` | a command line over 2048 bytes; the rest of it is skipped |
|
|
193
|
+
| `421 4.3.2 <hostname> Too many connections, try later` | a connection past `maxConnections`, then the server hangs up |
|
|
194
|
+
| `421 4.4.2 <hostname> Idle too long, closing` | nothing for `timeout` seconds |
|
|
195
|
+
| `421 4.7.0 <hostname> Too many errors, closing` | `maxErrors` failed commands |
|
|
196
|
+
| `421 4.3.0 Local error, closing` | the server failed itself |
|
|
197
|
+
|
|
198
|
+
STARTTLS and AUTH have replies of their own, under [TLS](#tls) and
|
|
199
|
+
[Authentication](#authentication).
|
|
200
|
+
|
|
201
|
+
Every command the server refuses of its own counts towards `maxErrors` —
|
|
202
|
+
syntax, order, a parameter, relaying, an unknown verb, a failed AUTH —
|
|
203
|
+
except `452 4.5.3`, `451 4.3.0`, `454 4.7.0 Temporary authentication
|
|
204
|
+
failure`, and the refusals at the end of DATA (`552 5.3.4`, `550 5.6.11`).
|
|
205
|
+
A refusal from your own hook never counts.
|
|
206
|
+
|
|
207
|
+
## Hooks, and their order
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
export type HookResult = Reply | undefined | void;
|
|
211
|
+
|
|
212
|
+
export interface SmtpHooks {
|
|
213
|
+
onConnect?(session: Session): HookResult | Promise<HookResult>;
|
|
214
|
+
onMailFrom?(path: Path, session: Session): HookResult | Promise<HookResult>;
|
|
215
|
+
onRcptTo?(path: Path, session: Session): HookResult | Promise<HookResult>;
|
|
216
|
+
onData(message: ReceivedMessage, session: Session): HookResult | Promise<HookResult>;
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A hook accepts by returning nothing, and refuses by returning a `Reply` —
|
|
221
|
+
build one with `reply(code, status, text)`, a 4xx code for "try again
|
|
222
|
+
later", a 5xx for "no". The reply is sent as given.
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { reply } from '@bumail/smtp';
|
|
226
|
+
|
|
227
|
+
reply(550, '5.1.1', 'No such user here'); // 550 5.1.1 No such user here
|
|
228
|
+
reply(450, '4.2.0', 'Mailbox busy, try later'); // a temporary refusal
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
In the order a session meets them:
|
|
232
|
+
|
|
233
|
+
| Hook | Runs | After the server checked | A refusal |
|
|
234
|
+
| --- | --- | --- | --- |
|
|
235
|
+
| `onConnect` | when a client connects, before the greeting | the connection count | replaces the greeting, at once even with `greetingDelay`; then the server hangs up. When `onConnect` accepts, a client that talked before the greeting is refused with `554` |
|
|
236
|
+
| `authenticate` | at AUTH | TLS, EHLO, no transaction in progress | see [Authentication](#authentication) |
|
|
237
|
+
| `onMailFrom` | at each MAIL FROM | EHLO, AUTH in `submission`, syntax, parameters, SIZE, SMTPUTF8 | refuses the sender; the client may try another MAIL |
|
|
238
|
+
| `onRcptTo` | at each RCPT TO | MAIL, syntax, SMTPUTF8, `maxRecipients`, **relaying** | refuses that recipient; the others stand |
|
|
239
|
+
| `onData` | at DATA, with the message as a stream, while the client sends it | a recipient was accepted | refuses the message, once it ended; the next transaction starts clean |
|
|
240
|
+
|
|
241
|
+
A hook is never asked about what the server already refused: `onRcptTo`
|
|
242
|
+
never sees a recipient refused for relaying, so no hook can open the relay.
|
|
243
|
+
|
|
244
|
+
Hooks run one at a time, in the order of the commands. A client that
|
|
245
|
+
pipelines gets every reply in order: a slow `onMailFrom` holds the replies to
|
|
246
|
+
the commands behind it.
|
|
247
|
+
|
|
248
|
+
A hook that throws, whose promise rejects, that does not settle within
|
|
249
|
+
`hookTimeout` seconds, or that answers a reply under 400 refuses with
|
|
250
|
+
`451 4.3.0 Local error in processing` — a temporary failure, so the sender
|
|
251
|
+
keeps the message and tries again. A reply under 400 is not a refusal:
|
|
252
|
+
sending it would tell the client yes while the server did not take the
|
|
253
|
+
command. Each of these goes to `onError`, with an `SmtpError` of code
|
|
254
|
+
`HOOK_TIMEOUT` or `INVALID_HOOK_REPLY`, or the error your hook threw; an
|
|
255
|
+
`onData` that answers without reading the message to its end, with
|
|
256
|
+
`MESSAGE_NOT_READ`. Let `onData` throw when the message could not be
|
|
257
|
+
stored:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { createSmtpServer, reply } from '@bumail/smtp';
|
|
261
|
+
|
|
262
|
+
const known = new Set(['alice@example.com', 'bob@example.com']);
|
|
263
|
+
|
|
264
|
+
const server = createSmtpServer({
|
|
265
|
+
hostname: 'mx.example.com',
|
|
266
|
+
localDomains: ['example.com'],
|
|
267
|
+
onRcptTo: (path) =>
|
|
268
|
+
known.has(`${path.local.toLowerCase()}@${path.domain}`)
|
|
269
|
+
? undefined
|
|
270
|
+
: reply(550, '5.1.1', 'No such user here'),
|
|
271
|
+
async onData(message) {
|
|
272
|
+
if (message.envelope.from === '') console.log('a bounce');
|
|
273
|
+
const bytes = await new Response(message.content).bytes();
|
|
274
|
+
await Bun.write(`spool/${message.id}.eml`, bytes); // a throw here: 451 4.3.0
|
|
275
|
+
},
|
|
276
|
+
onError: (error, session) => console.error(`[${session.id}]`, error),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await server.listen({ port: 25 });
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`onMailFrom` and `onRcptTo` receive the `Path` the client gave:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
export interface Path {
|
|
286
|
+
/** `local@domain`; `''` for the null reverse-path `<>`: a bounce. */
|
|
287
|
+
readonly address: string;
|
|
288
|
+
/** As sent: case is kept. */
|
|
289
|
+
readonly local: string;
|
|
290
|
+
/** Lower case. */
|
|
291
|
+
readonly domain: string;
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
A source route (`<@a.example:b@c.example>`, RFC 5321 Appendix C) is dropped:
|
|
296
|
+
the path is `b@c.example`.
|
|
297
|
+
|
|
298
|
+
`local` is kept as written, quotes included. `<"v@x.example"@example.com>`
|
|
299
|
+
and `<v%x.example@example.com>` are local parts of `example.com`, and
|
|
300
|
+
passed the relay check as such: code that delivers must take the domain
|
|
301
|
+
after the last `@` — `path.domain` — and never split `address` on its
|
|
302
|
+
first `@`. C1 controls, Unicode format characters (zero-width, bidi, BOM)
|
|
303
|
+
and U+2028/2029 are refused in a path, even under SMTPUTF8.
|
|
304
|
+
|
|
305
|
+
## Session.data
|
|
306
|
+
|
|
307
|
+
Every hook, and `authenticate`, receives the `Session`:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
export interface Session {
|
|
311
|
+
readonly id: string;
|
|
312
|
+
readonly remoteAddress: string;
|
|
313
|
+
/** TLS is on: implicit, or after STARTTLS. */
|
|
314
|
+
readonly secure: boolean;
|
|
315
|
+
/** The name the client gave in EHLO or HELO. */
|
|
316
|
+
readonly helo?: string;
|
|
317
|
+
/** The client said EHLO, not HELO. */
|
|
318
|
+
readonly esmtp: boolean;
|
|
319
|
+
/** The username the client authenticated as. */
|
|
320
|
+
readonly user?: string;
|
|
321
|
+
/** For the app's own state across hooks. */
|
|
322
|
+
readonly data: Record<string, unknown>;
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The `Session` a hook receives is a snapshot of that moment: read
|
|
327
|
+
`session.user` in the hook, not from an object kept since `onConnect`.
|
|
328
|
+
`data` is the one object that is shared: the same record for every hook of
|
|
329
|
+
the connection, for you to keep what one hook learnt for the next. It lasts
|
|
330
|
+
as long as the connection — across transactions, RSET and STARTTLS — and
|
|
331
|
+
the server never reads it. STARTTLS makes the server forget what it learnt
|
|
332
|
+
in clear (RFC 3207 §4.2), but not `data`: what you keep there, and whether
|
|
333
|
+
it still holds once encrypted, is yours to judge.
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
import { createSmtpServer, reply } from '@bumail/smtp';
|
|
337
|
+
|
|
338
|
+
const server = createSmtpServer({
|
|
339
|
+
hostname: 'mx.example.com',
|
|
340
|
+
localDomains: ['example.com'],
|
|
341
|
+
async onConnect(session) {
|
|
342
|
+
const listed = await isListed(session.remoteAddress);
|
|
343
|
+
session.data['listed'] = listed;
|
|
344
|
+
},
|
|
345
|
+
onRcptTo(path, session) {
|
|
346
|
+
if (session.data['listed'] === true && path.local !== 'postmaster') {
|
|
347
|
+
return reply(554, '5.7.1', `${session.remoteAddress} is listed`);
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
},
|
|
351
|
+
async onData(message) {
|
|
352
|
+
await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await server.listen({ port: 25 });
|
|
357
|
+
|
|
358
|
+
async function isListed(address: string): Promise<boolean> {
|
|
359
|
+
return address.startsWith('203.0.113.');
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Values come out as `unknown`; narrow them where you read them. Under
|
|
364
|
+
`noPropertyAccessFromIndexSignature`, write `session.data['listed']`.
|
|
365
|
+
|
|
366
|
+
## What onData receives
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
export interface ReceivedMessage {
|
|
370
|
+
/** The id the server gave it, also in its Received field and the 250 reply. */
|
|
371
|
+
readonly id: string;
|
|
372
|
+
readonly envelope: Envelope;
|
|
373
|
+
/**
|
|
374
|
+
* The message as it arrives, dot-unstuffed, with the server's Received
|
|
375
|
+
* field first. It errors with an `SmtpError` when the message must not be
|
|
376
|
+
* delivered.
|
|
377
|
+
*/
|
|
378
|
+
readonly content: ReadableStream<Uint8Array>;
|
|
379
|
+
/**
|
|
380
|
+
* Aborts when the server refuses the message on `onData`'s behalf; its
|
|
381
|
+
* `reason` says why. A refusal `onData` returns leaves it alone, unless
|
|
382
|
+
* the client never hears it: a later stream failure (whose 552 or 550
|
|
383
|
+
* replaces `onData`'s reply) or a closed connection still aborts it,
|
|
384
|
+
* with that error.
|
|
385
|
+
*/
|
|
386
|
+
readonly signal: AbortSignal;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface Envelope {
|
|
390
|
+
/** `''` for the null reverse-path `<>`: a bounce. */
|
|
391
|
+
readonly from: string;
|
|
392
|
+
readonly to: readonly string[];
|
|
393
|
+
/** The client asked for SMTPUTF8 (RFC 6531). */
|
|
394
|
+
readonly smtputf8: boolean;
|
|
395
|
+
/** `7BIT` or `8BITMIME`, as MAIL FROM's BODY= said; `7BIT` when it said nothing. */
|
|
396
|
+
readonly body: '7BIT' | '8BITMIME';
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
The **envelope** is what MAIL FROM and RCPT TO said — not the `From:` and
|
|
401
|
+
`To:` of the message, which may differ (Bcc, mailing lists, forwarding).
|
|
402
|
+
`to` holds only the recipients the server and `onRcptTo` accepted. Deliver
|
|
403
|
+
to the envelope.
|
|
404
|
+
|
|
405
|
+
The **content** is the message as the client sent it, with the leading dot
|
|
406
|
+
of each stuffed line removed (RFC 5321 §4.5.2), the terminating `.` line
|
|
407
|
+
gone, and CRLF line endings. On top, the server adds its Received field
|
|
408
|
+
(RFC 5321 §4.4):
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
Received: from bar.com ([192.0.2.10])
|
|
412
|
+
by foo.com with ESMTP id 3f9c0a1b2c3d4e5f6a7b
|
|
413
|
+
for <b@foo.com>; Fri, 2 Oct 2026 14:03:27 +0000
|
|
414
|
+
Subject: hi
|
|
415
|
+
|
|
416
|
+
body
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
- `from` is the EHLO or HELO name, then the client's address in brackets.
|
|
420
|
+
Any character of the name that is not printable ASCII is written `?`.
|
|
421
|
+
- `with` is the protocol of RFC 3848: `SMTP` after HELO; `ESMTP` after EHLO,
|
|
422
|
+
`ESMTPS` with TLS, `ESMTPA` with AUTH, `ESMTPSA` with both.
|
|
423
|
+
- `id` is the message's `id`, as in `250 2.0.0 OK queued as <id>`: 20
|
|
424
|
+
hexadecimal characters.
|
|
425
|
+
- `for` names the recipient when there is exactly one, and only when it is
|
|
426
|
+
printable ASCII with no `;`; otherwise it is left out.
|
|
427
|
+
- The date is in UTC.
|
|
428
|
+
|
|
429
|
+
`onData` is called at DATA, as soon as the client may send, with `content`
|
|
430
|
+
a stream of the message as it arrives. The server holds about 64 KiB of it:
|
|
431
|
+
when `onData` reads slower than the client sends, the server stops reading
|
|
432
|
+
the client until `onData` catches up. Read it to its end, then store the
|
|
433
|
+
message:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
437
|
+
|
|
438
|
+
const server = createSmtpServer({
|
|
439
|
+
hostname: 'mx.example.com',
|
|
440
|
+
localDomains: ['example.com'],
|
|
441
|
+
async onData(message) {
|
|
442
|
+
const path = `spool/${message.id}.eml`;
|
|
443
|
+
try {
|
|
444
|
+
await Bun.write(path, new Response(message.content));
|
|
445
|
+
} catch (error) {
|
|
446
|
+
await Bun.file(path).delete().catch(() => undefined); // never keep half a message
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await server.listen({ port: 25 });
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The client is told `250` only once `onData` read the stream to its clean
|
|
456
|
+
end **and** resolved without refusing. The stream errors, with an
|
|
457
|
+
`SmtpError`, when the message must not be delivered — and the client is
|
|
458
|
+
then refused whatever `onData` answers:
|
|
459
|
+
|
|
460
|
+
| `code` | Why | The client gets |
|
|
461
|
+
| --- | --- | --- |
|
|
462
|
+
| `MESSAGE_TOO_BIG` | the message passed `maxMessageSize` | `552 5.3.4 Message too big for system` |
|
|
463
|
+
| `BARE_LINE_BREAK` | a CR or LF that is not part of a CRLF: SMTP smuggling | `550 5.6.11 Bare CR or LF is not allowed in a message` |
|
|
464
|
+
| `CONNECTION_LOST` | the connection closed before the end — the client hung up, or the server closed it on idle `timeout` or a socket error | nothing: it is gone |
|
|
465
|
+
| `MESSAGE_NOT_READ` | `onData` answered before the end: a read it left running errors the moment it answers | `451 4.3.0 Local error in processing` |
|
|
466
|
+
| `HOOK_TIMEOUT` | `onData` read nothing for `hookTimeout` seconds, or did not answer within `hookTimeout` once the message ended — a late read must not keep a message the client will send again | `451 4.3.0 Local error in processing` |
|
|
467
|
+
|
|
468
|
+
The stream errors at once; the rest of the message is read and dropped,
|
|
469
|
+
and the refusal goes out when the client ends DATA with `<CRLF>.<CRLF>`,
|
|
470
|
+
since SMTP has no reply before then. The session goes on. `onData` itself must settle
|
|
471
|
+
within `hookTimeout` once the message ended, or the client gets `451 4.3.0`.
|
|
472
|
+
|
|
473
|
+
An `onData` that resolves without a refusal before it read the stream to
|
|
474
|
+
its end — or that cancels the stream — has not taken the message, so the
|
|
475
|
+
server does not say it did: it drops the rest, answers
|
|
476
|
+
`451 4.3.0 Local error in processing`, and `onError` gets an `SmtpError` of
|
|
477
|
+
code `MESSAGE_NOT_READ`. The client keeps the message and tries again. An
|
|
478
|
+
`onData` that refuses may do so without reading; its refusal is sent:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import { createSmtpServer, reply } from '@bumail/smtp';
|
|
482
|
+
|
|
483
|
+
const server = createSmtpServer({
|
|
484
|
+
hostname: 'mx.example.com',
|
|
485
|
+
localDomains: ['example.com'],
|
|
486
|
+
async onData(message) {
|
|
487
|
+
if (message.envelope.to.length > 20) return reply(550, '5.7.1', 'Too many recipients for one message');
|
|
488
|
+
await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes()); // read to the end
|
|
489
|
+
return undefined;
|
|
490
|
+
},
|
|
491
|
+
onError: (error, session) => console.error(`[${session.id}]`, error), // MESSAGE_NOT_READ lands here
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
await server.listen({ port: 25 });
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### When the refusal comes after the read
|
|
498
|
+
|
|
499
|
+
One refusal cannot reach the stream: `onData` read the message to its
|
|
500
|
+
clean end, then took longer than `hookTimeout` to answer — a slow scan, a
|
|
501
|
+
slow disk. The client gets `451 4.3.0` and will send the message again, but
|
|
502
|
+
the read already succeeded. `message.signal` covers that case and the
|
|
503
|
+
others: it aborts whenever the server refuses the message on `onData`'s
|
|
504
|
+
behalf.
|
|
505
|
+
|
|
506
|
+
| `signal.reason` | when |
|
|
507
|
+
| --- | --- |
|
|
508
|
+
| `SmtpError` `HOOK_TIMEOUT` | `onData` did not answer, or did not read, within `hookTimeout` |
|
|
509
|
+
| `SmtpError` `MESSAGE_NOT_READ` | `onData` accepted (answered `undefined`) before reading to the end, or after cancelling the stream |
|
|
510
|
+
| `SmtpError` `INVALID_HOOK_REPLY` | `onData` answered what is not a refusal, such as a `250` |
|
|
511
|
+
| what `onData` threw | `onData` threw; the client got `451` |
|
|
512
|
+
| `SmtpError` `CONNECTION_LOST` | the connection closed — the client hung up, or the server closed it on idle or a socket error — before the end of the message, or after it but before the reply |
|
|
513
|
+
| `SmtpError` `MESSAGE_TOO_BIG`, `BARE_LINE_BREAK` | the stream's own errors |
|
|
514
|
+
|
|
515
|
+
A refusal `onData` returns leaves the signal alone, whether it read the
|
|
516
|
+
message or not (a read it left running still errors with
|
|
517
|
+
`MESSAGE_NOT_READ`), unless the client never hears it: a later stream
|
|
518
|
+
failure (whose 552 or 550 replaces `onData`'s reply) or a closed
|
|
519
|
+
connection still aborts it, with that error. A client that leaves once
|
|
520
|
+
the reply was sent does not abort it. Check it, or listen for `abort`,
|
|
521
|
+
before keeping a message for good:
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
525
|
+
|
|
526
|
+
const server = createSmtpServer({
|
|
527
|
+
hostname: 'mx.example.com',
|
|
528
|
+
localDomains: ['example.com'],
|
|
529
|
+
async onData(message) {
|
|
530
|
+
const bytes = await new Response(message.content).bytes();
|
|
531
|
+
// A scan that may outlast hookTimeout.
|
|
532
|
+
await fetch('http://127.0.0.1:3310/scan', { method: 'POST', body: bytes });
|
|
533
|
+
if (message.signal.aborted) return; // the client was told 451: it will send it again
|
|
534
|
+
await Bun.write(`spool/${message.id}.eml`, bytes);
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
await server.listen({ port: 25 });
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
A check just before the commit narrows the race to that last write; it
|
|
542
|
+
cannot close it. RFC 5321 §6.1 prefers a duplicate to a loss, so the worst
|
|
543
|
+
case is a second copy, never a lost message.
|
|
544
|
+
|
|
545
|
+
To parse the message, collect it first and give the bytes to a parser.
|
|
546
|
+
`@bumail/mime`, a sibling package, is on the
|
|
547
|
+
[bumail roadmap](https://github.com/softistx/bumail/blob/develop/docs/roadmap.md);
|
|
548
|
+
until it is published, a parser of your own takes the same bytes:
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
552
|
+
|
|
553
|
+
const server = createSmtpServer({
|
|
554
|
+
hostname: 'mx.example.com',
|
|
555
|
+
localDomains: ['example.com'],
|
|
556
|
+
async onData(message) {
|
|
557
|
+
const text = await new Response(message.content).text();
|
|
558
|
+
const [head = ''] = text.split('\r\n\r\n', 1);
|
|
559
|
+
const subject = /^subject: *(.*)$/im.exec(head)?.[1];
|
|
560
|
+
console.log(subject, 'for', message.envelope.to);
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
await server.listen({ port: 25 });
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
## Delivering into @bumail/store
|
|
568
|
+
|
|
569
|
+
[`@bumail/store`](https://github.com/softistx/bumail/tree/develop/packages/store) keeps
|
|
570
|
+
accounts, mailboxes and messages behind one contract, with a memory store
|
|
571
|
+
to start. `@bumail/smtp` does not depend on it: `onData` hands the stream
|
|
572
|
+
to the store, and the store reads it.
|
|
573
|
+
|
|
574
|
+
`message.content` already starts with the server's `Received` field, so it
|
|
575
|
+
goes into `addMessage` as it is — no buffer of your own, no header to
|
|
576
|
+
prepend. `addMessage` keeps nothing unless the stream ends cleanly, so a
|
|
577
|
+
message the server refuses mid-way (too big, a bare LF, the connection gone)
|
|
578
|
+
is stored nowhere, and the client hears the refusal.
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
import { createSmtpServer, reply } from '@bumail/smtp';
|
|
582
|
+
import { MemoryMailStore } from '@bumail/store';
|
|
583
|
+
|
|
584
|
+
const store = new MemoryMailStore();
|
|
585
|
+
const accounts = new Map<string, string>(); // address → account id
|
|
586
|
+
for (const name of ['alice', 'bob']) {
|
|
587
|
+
const account = await store.createAccount(name);
|
|
588
|
+
await store.createMailbox(account.id, { name: 'INBOX', role: 'inbox' });
|
|
589
|
+
accounts.set(`${name}@example.com`, account.id);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const server = createSmtpServer({
|
|
593
|
+
hostname: 'mx.example.com',
|
|
594
|
+
localDomains: ['example.com'],
|
|
595
|
+
// Refuse unknown users at RCPT, before any byte of the message.
|
|
596
|
+
onRcptTo: (path) =>
|
|
597
|
+
accounts.has(path.address.toLowerCase())
|
|
598
|
+
? undefined
|
|
599
|
+
: reply(550, '5.1.1', 'No such user here'),
|
|
600
|
+
// One copy per recipient: the stream is teed, not buffered by you.
|
|
601
|
+
async onData({ envelope, content }) {
|
|
602
|
+
let rest = content;
|
|
603
|
+
await Promise.all(
|
|
604
|
+
envelope.to.map(async (to, index) => {
|
|
605
|
+
let mine = rest;
|
|
606
|
+
if (index < envelope.to.length - 1) [mine, rest] = rest.tee();
|
|
607
|
+
const accountId = accounts.get(to.toLowerCase()) ?? '';
|
|
608
|
+
const inbox = await store.findMailbox(accountId, 'inbox');
|
|
609
|
+
await store.addMessage(accountId, inbox?.id ?? '', { content: mine });
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
},
|
|
613
|
+
onError: (error, session) => console.error(`[${session.id}]`, error),
|
|
614
|
+
});
|
|
615
|
+
await server.listen({ port: 25 });
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
- **Every copy, or a 451.** `onData` resolves once each `addMessage` has
|
|
619
|
+
read its branch to the end, so the `250` means every recipient has the
|
|
620
|
+
message. If one `addMessage` throws, `onData` throws: the client gets
|
|
621
|
+
`451 4.3.0` and sends again — and a recipient whose copy was already
|
|
622
|
+
added gets it twice. A server that must avoid that queues the message
|
|
623
|
+
once, then delivers from the queue.
|
|
624
|
+
- **A relay is refused before `onData`.** Without AUTH, a recipient
|
|
625
|
+
outside `localDomains` gets `554 5.7.1 Relay access denied` at RCPT, so
|
|
626
|
+
nothing reaches the store.
|
|
627
|
+
- **The memory store holds every message in memory.** It reads the stream
|
|
628
|
+
into one `Blob`; a store on disk is on the store's roadmap.
|
|
629
|
+
|
|
630
|
+
## Authentication
|
|
631
|
+
|
|
632
|
+
`authenticate` turns on AUTH PLAIN (RFC 4616) and LOGIN, over TLS only.
|
|
633
|
+
It returns `true` to accept the credentials:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
export interface Credentials {
|
|
637
|
+
readonly mechanism: 'PLAIN' | 'LOGIN';
|
|
638
|
+
readonly username: string;
|
|
639
|
+
readonly password: string;
|
|
640
|
+
/** PLAIN's authorization identity, when it differs from the username: the server refuses those. */
|
|
641
|
+
readonly authorizationId?: string;
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import { createSmtpServer, reply } from '@bumail/smtp';
|
|
647
|
+
|
|
648
|
+
const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
|
|
649
|
+
|
|
650
|
+
const server = createSmtpServer({
|
|
651
|
+
hostname: 'smtp.example.com',
|
|
652
|
+
mode: 'submission',
|
|
653
|
+
localDomains: ['example.com'],
|
|
654
|
+
tls: {
|
|
655
|
+
key: await Bun.file('/etc/ssl/smtp.example.com.key').text(),
|
|
656
|
+
cert: await Bun.file('/etc/ssl/smtp.example.com.crt').text(),
|
|
657
|
+
},
|
|
658
|
+
async authenticate(credentials) {
|
|
659
|
+
const hash = users.get(credentials.username);
|
|
660
|
+
return hash !== undefined && (await Bun.password.verify(credentials.password, hash));
|
|
661
|
+
},
|
|
662
|
+
// A user sends as themself only.
|
|
663
|
+
onMailFrom: (path, session) =>
|
|
664
|
+
path.address === `${session.user}@example.com`
|
|
665
|
+
? undefined
|
|
666
|
+
: reply(553, '5.7.1', 'Sender address not owned by you'),
|
|
667
|
+
async onData(message) {
|
|
668
|
+
await Bun.write(`outbox/${message.id}.eml`, await new Response(message.content).bytes());
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
await server.listen({ port: 587 });
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
`session.user` becomes the `username`. A PLAIN authorization identity
|
|
676
|
+
other than the username is refused with `535 5.7.8` before `authenticate`
|
|
677
|
+
is asked: a session acts as the user it authenticated as, never as another.
|
|
678
|
+
So `authenticate` never sees an `authorizationId`; `decodePlain` still
|
|
679
|
+
returns it, for a server of your own.
|
|
680
|
+
The `AUTH=` parameter of MAIL FROM is accepted and ignored.
|
|
681
|
+
|
|
682
|
+
`authenticate` must return `true` to accept: any other value refuses. If it
|
|
683
|
+
throws or does not settle within `hookTimeout`, the client gets `454 4.7.0`
|
|
684
|
+
and `onError` is told.
|
|
685
|
+
|
|
686
|
+
| Reply | When |
|
|
687
|
+
| --- | --- |
|
|
688
|
+
| `334 ` / `334 VXNlcm5hbWU6` / `334 UGFzc3dvcmQ6` | a challenge: send the response, base64 |
|
|
689
|
+
| `235 2.7.0 Authentication successful` | `authenticate` returned `true`; AUTH is no longer advertised |
|
|
690
|
+
| `535 5.7.8 Authentication credentials invalid` | it returned anything else |
|
|
691
|
+
| `421 4.7.0 <hostname> Too many failed authentications, closing` | the third failure; the server hangs up |
|
|
692
|
+
| `454 4.7.0 Temporary authentication failure` | `authenticate` threw (RFC 4954 §6) |
|
|
693
|
+
| `538 5.7.11 Encryption required for requested authentication mechanism` | AUTH on a clear connection; the credentials are not read |
|
|
694
|
+
| `502 5.5.1 AUTH not available` | no `authenticate` option |
|
|
695
|
+
| `504 5.5.4 Unrecognized authentication type` | a mechanism other than PLAIN and LOGIN |
|
|
696
|
+
| `501 5.5.2 Cannot decode the response` | a response that is not valid base64 or PLAIN |
|
|
697
|
+
| `501 5.5.4 Syntax: AUTH mechanism [initial-response]` | AUTH with more than a mechanism and a response |
|
|
698
|
+
| `501 5.0.0 Authentication cancelled` | the client answered a challenge with `*` |
|
|
699
|
+
| `503 5.5.1 Already authenticated` / `AUTH not allowed during a transaction` | AUTH a second time, or between MAIL and the end of DATA |
|
|
700
|
+
| `503 Send EHLO first` | AUTH before EHLO, or after HELO; no enhanced code |
|
|
701
|
+
|
|
702
|
+
An authenticated session may send to any domain: that is what makes a
|
|
703
|
+
submission server useful. In `mx` mode, a session that authenticated may
|
|
704
|
+
relay too.
|
|
705
|
+
|
|
706
|
+
## TLS
|
|
707
|
+
|
|
708
|
+
```ts
|
|
709
|
+
export interface TlsOptions {
|
|
710
|
+
readonly key: string | Uint8Array | Bun.BunFile;
|
|
711
|
+
readonly cert: string | Uint8Array | Bun.BunFile;
|
|
712
|
+
}
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
`key` and `cert` are PEM, as `Bun.listen` takes them. Read them once, at
|
|
716
|
+
start-up, with `Bun.file().text()`; a missing file then fails before the
|
|
717
|
+
server listens, not at a client's first STARTTLS:
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
721
|
+
|
|
722
|
+
const tls = {
|
|
723
|
+
key: await Bun.file('/etc/ssl/mx.example.com.key').text(),
|
|
724
|
+
cert: await Bun.file('/etc/ssl/mx.example.com.crt').text(), // the chain, leaf first
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const mx = createSmtpServer({
|
|
728
|
+
hostname: 'mx.example.com',
|
|
729
|
+
localDomains: ['example.com'],
|
|
730
|
+
tls,
|
|
731
|
+
onData: async (message) => {
|
|
732
|
+
await new Response(message.content).bytes();
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
await mx.listen({ port: 25 });
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
For a local test, a self-signed pair:
|
|
740
|
+
|
|
741
|
+
```sh
|
|
742
|
+
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj /CN=localhost \
|
|
743
|
+
-keyout localhost.key -out localhost.crt
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
**STARTTLS** (RFC 3207) is offered when `tls` is set and the connection is
|
|
747
|
+
clear. After `220 2.0.0 Ready to start TLS` the server starts the handshake,
|
|
748
|
+
and the session starts over (§4.2): the client must send EHLO again, and the
|
|
749
|
+
transaction in progress is forgotten. Commands the client pipelined behind
|
|
750
|
+
STARTTLS, in clear, are dropped and never answered. The STARTTLS replies:
|
|
751
|
+
|
|
752
|
+
| Reply | When |
|
|
753
|
+
| --- | --- |
|
|
754
|
+
| `220 2.0.0 Ready to start TLS` | start the handshake |
|
|
755
|
+
| `454 4.7.0 TLS not available` | no `tls` option |
|
|
756
|
+
| `503 5.5.1 TLS already active` | the connection is already encrypted |
|
|
757
|
+
| `501 5.5.4 Syntax: STARTTLS` | STARTTLS with an argument |
|
|
758
|
+
|
|
759
|
+
**Implicit TLS** (RFC 8314) encrypts from the first byte: `implicitTls: true`
|
|
760
|
+
with `tls`, usually on 465. STARTTLS is not offered there — the connection
|
|
761
|
+
is already secure — and AUTH is offered at once.
|
|
762
|
+
|
|
763
|
+
An MX takes mail in clear as well as encrypted: senders on the Internet that
|
|
764
|
+
cannot do TLS still deliver. Whether to refuse them is policy — check
|
|
765
|
+
`session.secure` in a hook:
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
import { createSmtpServer, reply } from '@bumail/smtp';
|
|
769
|
+
|
|
770
|
+
const tls = {
|
|
771
|
+
key: await Bun.file('/etc/ssl/mx.example.com.key').text(),
|
|
772
|
+
cert: await Bun.file('/etc/ssl/mx.example.com.crt').text(),
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
const server = createSmtpServer({
|
|
776
|
+
hostname: 'mx.example.com',
|
|
777
|
+
localDomains: ['example.com'],
|
|
778
|
+
tls,
|
|
779
|
+
onMailFrom: (_, session) =>
|
|
780
|
+
session.secure ? undefined : reply(530, '5.7.0', 'Must issue a STARTTLS command first'),
|
|
781
|
+
onData: async (message) => {
|
|
782
|
+
await new Response(message.content).bytes();
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
await server.listen({ port: 25 });
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
To run an MX on 25, submission on 587 and implicit TLS on 465 in one
|
|
790
|
+
process, create three servers with the same `tls`:
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
import { createSmtpServer, type ReceivedMessage, type SmtpServerOptions } from '@bumail/smtp';
|
|
794
|
+
|
|
795
|
+
const tls = {
|
|
796
|
+
key: await Bun.file('/etc/ssl/mail.example.com.key').text(),
|
|
797
|
+
cert: await Bun.file('/etc/ssl/mail.example.com.crt').text(),
|
|
798
|
+
};
|
|
799
|
+
const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
|
|
800
|
+
|
|
801
|
+
async function deliver(message: ReceivedMessage): Promise<void> {
|
|
802
|
+
await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const shared = {
|
|
806
|
+
hostname: 'mail.example.com',
|
|
807
|
+
localDomains: ['example.com'],
|
|
808
|
+
tls,
|
|
809
|
+
onData: deliver,
|
|
810
|
+
} satisfies SmtpServerOptions;
|
|
811
|
+
|
|
812
|
+
const submission = {
|
|
813
|
+
...shared,
|
|
814
|
+
mode: 'submission',
|
|
815
|
+
async authenticate({ username, password }) {
|
|
816
|
+
const hash = users.get(username);
|
|
817
|
+
return hash !== undefined && (await Bun.password.verify(password, hash));
|
|
818
|
+
},
|
|
819
|
+
} satisfies SmtpServerOptions;
|
|
820
|
+
|
|
821
|
+
await createSmtpServer(shared).listen({ port: 25 });
|
|
822
|
+
await createSmtpServer(submission).listen({ port: 587 });
|
|
823
|
+
await createSmtpServer({ ...submission, implicitTls: true }).listen({ port: 465 });
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
## Limits
|
|
827
|
+
|
|
828
|
+
| Limit | Default | Past it |
|
|
829
|
+
| --- | --- | --- |
|
|
830
|
+
| `maxMessageSize` | 25 MiB | `552 5.3.4` at MAIL when `SIZE=` says more; during DATA once the message is more — the content stream errors with `MESSAGE_TOO_BIG`, the rest is read and dropped, and the session goes on |
|
|
831
|
+
| `maxRecipients` | 100, the least RFC 5321 §4.5.3.1.8 asks a server to take | `452 4.5.3` for each extra recipient (RFC 5321 §4.5.3.1.10); the client sends the rest in another transaction |
|
|
832
|
+
| `maxConnections` | 1000 | `421 4.3.2` and the server hangs up, before the greeting and before `onConnect` |
|
|
833
|
+
| `maxErrors` | 10 | `421 4.7.0` and the server hangs up |
|
|
834
|
+
| `timeout` | 300 seconds, RFC 5321 §4.5.3.2.7's, counted from the client's last byte, or from the 220; Bun's socket timer ticks in steps of about 4 s, so the hang-up comes up to that much later | `421 4.4.2` and the server hangs up |
|
|
835
|
+
| `hookTimeout` | 60 seconds | `451 4.3.0` for that command; `onError` gets an `SmtpError` `HOOK_TIMEOUT` |
|
|
836
|
+
| `greetingDelay` | 0 seconds | not a limit but a wait: the 220 goes out that long after `onConnect` accepted; a client that talks in the meantime gets `554 <hostname> Talked before the greeting` and the server hangs up |
|
|
837
|
+
|
|
838
|
+
`greetingDelay` is a cheap filter against spam engines that do not wait for
|
|
839
|
+
the greeting (RFC 5321 §4.3.1), in the manner of Postfix's postscreen: a few
|
|
840
|
+
seconds is usual. Each waiting client holds one of `maxConnections`, and
|
|
841
|
+
the idle `timeout` runs during the wait, so keep the delay well below it:
|
|
842
|
+
`createSmtpServer` refuses a delay as long as `timeout`. The idle time starts
|
|
843
|
+
again when the 220 goes out, so the wait before it, `onConnect`'s included,
|
|
844
|
+
is never taken from the client's `timeout`.
|
|
845
|
+
`onConnect` runs first; its refusal goes out at once, without the delay:
|
|
846
|
+
|
|
847
|
+
```ts
|
|
848
|
+
import { createSmtpServer, reply } from '@bumail/smtp';
|
|
849
|
+
|
|
850
|
+
const blocked = new Set(['203.0.113.7']);
|
|
851
|
+
|
|
852
|
+
const server = createSmtpServer({
|
|
853
|
+
hostname: 'mx.example.com',
|
|
854
|
+
localDomains: ['example.com'],
|
|
855
|
+
greetingDelay: 6,
|
|
856
|
+
onConnect: (session) => (blocked.has(session.remoteAddress) ? reply(554, '5.7.1', 'Go away') : undefined),
|
|
857
|
+
onData: async (message) => {
|
|
858
|
+
await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
|
|
859
|
+
},
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
await server.listen({ port: 25 });
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
Some limits are fixed: a command line is at most 2048 bytes (RFC 5321
|
|
866
|
+
§4.5.3.1.4 asks for 512 at least); a session gets three AUTH attempts; and
|
|
867
|
+
the server holds at most 64 KiB of a client's unread input, and of a
|
|
868
|
+
message `onData` has not read, before it stops reading that client. Replies
|
|
869
|
+
to a client that does not read them wait in the server, and the server reads
|
|
870
|
+
no further command until they went out.
|
|
871
|
+
|
|
872
|
+
Stop a server with `stop()`; `stop(true)` also hangs up on every client.
|
|
873
|
+
`connections` counts the clients currently connected:
|
|
874
|
+
|
|
875
|
+
```ts
|
|
876
|
+
import { createSmtpServer } from '@bumail/smtp';
|
|
877
|
+
|
|
878
|
+
const server = createSmtpServer({
|
|
879
|
+
hostname: 'mx.example.com',
|
|
880
|
+
localDomains: ['example.com'],
|
|
881
|
+
onData: async (message) => {
|
|
882
|
+
await new Response(message.content).bytes();
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
await server.listen({ port: 2525 });
|
|
886
|
+
|
|
887
|
+
process.on('SIGTERM', () => {
|
|
888
|
+
console.log(`stopping, ${server.connections} open`);
|
|
889
|
+
server.stop(true);
|
|
890
|
+
});
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
## Protocol helpers
|
|
894
|
+
|
|
895
|
+
The pieces the server is built from are exported, for a test, a client, or
|
|
896
|
+
a server of your own.
|
|
897
|
+
|
|
898
|
+
```ts
|
|
899
|
+
import {
|
|
900
|
+
DataReader,
|
|
901
|
+
decodePlain,
|
|
902
|
+
formatReply,
|
|
903
|
+
parseCommand,
|
|
904
|
+
parsePath,
|
|
905
|
+
parsePathCommand,
|
|
906
|
+
reply,
|
|
907
|
+
} from '@bumail/smtp';
|
|
908
|
+
|
|
909
|
+
parseCommand('ehlo bar.com'); // { verb: 'EHLO', argument: 'bar.com' }
|
|
910
|
+
|
|
911
|
+
parsePathCommand('FROM:<Smith@bar.com> SIZE=500000 BODY=8BITMIME', 'FROM');
|
|
912
|
+
// { path: { address: 'Smith@bar.com', local: 'Smith', domain: 'bar.com' },
|
|
913
|
+
// parameters: { SIZE: '500000', BODY: '8BITMIME' } }
|
|
914
|
+
|
|
915
|
+
parsePath('<Joe.Bloggs@EXAMPLE.Org>', false)?.address; // 'Joe.Bloggs@example.org'
|
|
916
|
+
parsePath('<>', true); // { address: '', local: '', domain: '' }: the null path, where allowed
|
|
917
|
+
parsePath('<>', false); // undefined
|
|
918
|
+
|
|
919
|
+
formatReply(reply(550, '5.7.1', ['a', 'b'])); // '550-5.7.1 a\r\n550 5.7.1 b\r\n'
|
|
920
|
+
formatReply(reply(250, '2.0.0', 'OK'), false); // '250 OK\r\n': no enhanced status
|
|
921
|
+
|
|
922
|
+
decodePlain('AHRpbQB0YW5zdGFhZnRhbnN0YWFm');
|
|
923
|
+
// { mechanism: 'PLAIN', username: 'tim', password: 'tanstaaftanstaaf' }
|
|
924
|
+
|
|
925
|
+
const reader = new DataReader();
|
|
926
|
+
const chunk = reader.write(new TextEncoder().encode('..x\r\n.\r\nQUIT\r\n'));
|
|
927
|
+
console.log(chunk.done, reader.bareLineBreaks);
|
|
928
|
+
// chunk.data: '.x\r\n', chunk.done: true, chunk.rest: 'QUIT\r\n'; reader.bareLineBreaks: 0
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
- `parsePathCommand` and `parsePath` return `undefined` when the syntax is
|
|
932
|
+
wrong. `parsePath` takes UTF-8 addresses; whether a session may use one
|
|
933
|
+
is SMTPUTF8's question.
|
|
934
|
+
- `formatReply` replaces a CR or LF in the text with a space, so a reply
|
|
935
|
+
text cannot start a second reply.
|
|
936
|
+
- `DataReader.write(chunk)` returns the unstuffed bytes, whether the
|
|
937
|
+
terminator was read, and what followed it. It ends a message only at the
|
|
938
|
+
exact `<CRLF>.<CRLF>`, and counts bare CRs and LFs in `bareLineBreaks`.
|
|
939
|
+
- `decodePlain` and `decodeLoginStep` return `undefined` for a response that
|
|
940
|
+
is not valid base64, not UTF-8, or (for PLAIN) not
|
|
941
|
+
`authzid NUL authcid NUL passwd` with a username and a password.
|
|
942
|
+
|
|
943
|
+
## The RFCs implemented
|
|
944
|
+
|
|
945
|
+
| Behaviour | RFC |
|
|
946
|
+
| --- | --- |
|
|
947
|
+
| the session, commands, replies, paths, the Received field | RFC 5321 |
|
|
948
|
+
| SIZE | RFC 1870 |
|
|
949
|
+
| PIPELINING | RFC 2920 |
|
|
950
|
+
| 8BITMIME | RFC 6152 |
|
|
951
|
+
| SMTPUTF8 | RFC 6531 |
|
|
952
|
+
| ENHANCEDSTATUSCODES and the codes themselves | RFC 2034, RFC 3463 |
|
|
953
|
+
| STARTTLS | RFC 3207 |
|
|
954
|
+
| implicit TLS | RFC 8314 |
|
|
955
|
+
| AUTH | RFC 4954 |
|
|
956
|
+
| SASL PLAIN | RFC 4616 |
|
|
957
|
+
| submission mode | RFC 6409 |
|
|
958
|
+
| `with ESMTPSA` in the Received field | RFC 3848 |
|
|
959
|
+
| the Received field's date | RFC 5322 §3.3 |
|
|
960
|
+
|
|
961
|
+
AUTH LOGIN has no RFC; it is the de facto mechanism most clients offer.
|
|
962
|
+
The specs next to each module are built on these RFCs' own examples —
|
|
963
|
+
Appendix D.1's session, RFC 4616's PLAIN responses — and name the section
|
|
964
|
+
each one comes from.
|
|
965
|
+
|
|
966
|
+
## What is not implemented
|
|
967
|
+
|
|
968
|
+
- **DSN** (RFC 3461). `RET`, `ENVID`, `NOTIFY` and `ORCPT` are refused with
|
|
969
|
+
`555 5.5.4 <PARAM> is not supported`; DSN is not advertised, so a
|
|
970
|
+
conforming client does not send them.
|
|
971
|
+
- **CHUNKING** (RFC 3030). `BDAT` is answered `500 5.5.2 Command
|
|
972
|
+
unrecognized`; a client sends DATA instead.
|
|
973
|
+
- **The client.** This package receives mail; it does not send it to other
|
|
974
|
+
servers. Outbound delivery is on the [roadmap](roadmap.md).
|
|
975
|
+
- **Other SASL mechanisms** — CRAM-MD5, SCRAM, XOAUTH2 get `504 5.5.4`.
|
|
976
|
+
- **EXPN**, which gives away a list's members: `500 5.5.2`.
|