@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/errors.d.ts +13 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/index.d.ts +9 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +1319 -0
  8. package/dist/index.js.map +29 -0
  9. package/dist/protocol/command.d.ts +20 -0
  10. package/dist/protocol/command.d.ts.map +1 -0
  11. package/dist/protocol/data.d.ts +24 -0
  12. package/dist/protocol/data.d.ts.map +1 -0
  13. package/dist/protocol/path.d.ts +20 -0
  14. package/dist/protocol/path.d.ts.map +1 -0
  15. package/dist/protocol/reply.d.ts +13 -0
  16. package/dist/protocol/reply.d.ts.map +1 -0
  17. package/dist/protocol/sasl.d.ts +16 -0
  18. package/dist/protocol/sasl.d.ts.map +1 -0
  19. package/dist/server/auth.d.ts +6 -0
  20. package/dist/server/auth.d.ts.map +1 -0
  21. package/dist/server/commands.d.ts +4 -0
  22. package/dist/server/commands.d.ts.map +1 -0
  23. package/dist/server/connection.d.ts +52 -0
  24. package/dist/server/connection.d.ts.map +1 -0
  25. package/dist/server/guard.d.ts +18 -0
  26. package/dist/server/guard.d.ts.map +1 -0
  27. package/dist/server/input.d.ts +31 -0
  28. package/dist/server/input.d.ts.map +1 -0
  29. package/dist/server/intake.d.ts +31 -0
  30. package/dist/server/intake.d.ts.map +1 -0
  31. package/dist/server/lines.d.ts +25 -0
  32. package/dist/server/lines.d.ts.map +1 -0
  33. package/dist/server/options.d.ts +151 -0
  34. package/dist/server/options.d.ts.map +1 -0
  35. package/dist/server/received.d.ts +11 -0
  36. package/dist/server/received.d.ts.map +1 -0
  37. package/dist/server/refusals.d.ts +16 -0
  38. package/dist/server/refusals.d.ts.map +1 -0
  39. package/dist/server/server.d.ts +25 -0
  40. package/dist/server/server.d.ts.map +1 -0
  41. package/dist/server/settings.d.ts +16 -0
  42. package/dist/server/settings.d.ts.map +1 -0
  43. package/dist/server/state.d.ts +24 -0
  44. package/dist/server/state.d.ts.map +1 -0
  45. package/dist/server/transaction.d.ts +6 -0
  46. package/dist/server/transaction.d.ts.map +1 -0
  47. package/dist/server/transport.d.ts +42 -0
  48. package/dist/server/transport.d.ts.map +1 -0
  49. package/docs/README.md +10 -0
  50. package/docs/guide.md +976 -0
  51. package/docs/roadmap.md +45 -0
  52. package/docs/troubleshooting.md +1308 -0
  53. 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`.