@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
@@ -0,0 +1,1308 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by the text you see: the message of the error thrown,
4
+ or the reply the server sends, as a client or a log prints it. `…` stands
5
+ for the part that varies, often the server's `hostname`.
6
+
7
+ A reply carries its enhanced status code (`5.7.1`, RFC 3463) only after
8
+ `EHLO`. After `HELO`, or before either, the same reply comes without it:
9
+ `554 Relay access denied`, not `554 5.7.1 Relay access denied`.
10
+
11
+ **Configuration**
12
+
13
+ - [`SmtpError: createSmtpServer(): "…" is not a host name`](#smtperror-createsmtpserver--is-not-a-host-name)
14
+ - [`SmtpError: createSmtpServer(): onData must be a function: it is where messages go`](#smtperror-createsmtpserver-ondata-must-be-a-function-it-is-where-messages-go)
15
+ - [`SmtpError: createSmtpServer(): implicitTls needs tls: { key, cert }`](#smtperror-createsmtpserver-implicittls-needs-tls--key-cert-)
16
+ - [`SmtpError: createSmtpServer(): submission takes mail only from authenticated users, so it needs authenticate`](#smtperror-createsmtpserver-submission-takes-mail-only-from-authenticated-users-so-it-needs-authenticate)
17
+ - [`SmtpError: createSmtpServer(): authenticate needs tls: { key, cert }, since AUTH is offered only once encrypted`](#smtperror-createsmtpserver-authenticate-needs-tls--key-cert--since-auth-is-offered-only-once-encrypted)
18
+ - [`SmtpError: createSmtpServer(): localDomains must be an array of domains or a function`](#smtperror-createsmtpserver-localdomains-must-be-an-array-of-domains-or-a-function)
19
+ - [`SmtpError: createSmtpServer(): … must be a positive integer, not …`](#smtperror-createsmtpserver--must-be-a-positive-integer-not-)
20
+ - [`SmtpError: createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not …`](#smtperror-createsmtpserver-greetingdelay-must-be-a-number-of-seconds-0-or-more-not-)
21
+ - [`SmtpError: createSmtpServer(): greetingDelay (… s) must be shorter than timeout (… s), or every client times out before the greeting`](#smtperror-createsmtpserver-greetingdelay--s-must-be-shorter-than-timeout--s-or-every-client-times-out-before-the-greeting)
22
+ - [`SmtpError: listen(): the server is already listening on …`](#smtperror-listen-the-server-is-already-listening-on-)
23
+
24
+ **Relaying and authentication**
25
+
26
+ - [`554 5.7.1 Relay access denied`](#554-571-relay-access-denied)
27
+ - [`AUTH` is missing from the EHLO reply](#auth-is-missing-from-the-ehlo-reply)
28
+ - [`538 5.7.11 Encryption required for requested authentication mechanism`](#538-5711-encryption-required-for-requested-authentication-mechanism)
29
+ - [`530 5.7.0 Authentication required`](#530-570-authentication-required)
30
+ - [`535 5.7.8 Authentication credentials invalid`](#535-578-authentication-credentials-invalid)
31
+ - [`454 4.7.0 Temporary authentication failure`](#454-470-temporary-authentication-failure)
32
+ - [`421 4.7.0 … Too many failed authentications, closing`](#421-470--too-many-failed-authentications-closing)
33
+ - [`501 5.5.2 Cannot decode the response`](#501-552-cannot-decode-the-response)
34
+ - [`504 5.5.4 Unrecognized authentication type`](#504-554-unrecognized-authentication-type)
35
+ - [`502 5.5.1 AUTH not available`](#502-551-auth-not-available)
36
+ - [`503 5.5.1 Already authenticated`](#503-551-already-authenticated)
37
+ - [`503 5.5.1 AUTH not allowed during a transaction`](#503-551-auth-not-allowed-during-a-transaction)
38
+ - [`501 5.0.0 Authentication cancelled`](#501-500-authentication-cancelled)
39
+
40
+ **Commands**
41
+
42
+ - [`501 Syntax: EHLO hostname`](#501-syntax-ehlo-hostname)
43
+ - [`503 Send EHLO first`](#503-send-ehlo-first)
44
+ - [`503 5.5.1 Send MAIL first`, `503 5.5.1 Nested MAIL command`](#503-551-send-mail-first-503-551-nested-mail-command)
45
+ - [`501 5.5.4 Syntax: …`](#501-554-syntax-)
46
+ - [`500 5.5.2 Command unrecognized`](#500-552-command-unrecognized)
47
+ - [`454 4.7.0 TLS not available`](#454-470-tls-not-available)
48
+ - [`503 5.5.1 TLS already active`](#503-551-tls-already-active)
49
+ - [`501 5.5.4 BODY is 7BIT or 8BITMIME`](#501-554-body-is-7bit-or-8bitmime)
50
+ - [`555 5.5.4 … is not supported`](#555-554--is-not-supported)
51
+ - [`555 … needs EHLO`](#555--needs-ehlo)
52
+ - [`553 5.6.7 A non-ASCII address needs SMTPUTF8`](#553-567-a-non-ascii-address-needs-smtputf8)
53
+ - [`500 5.5.6 Line too long`](#500-556-line-too-long)
54
+ - [`252 2.5.0 Cannot VRFY user; send the message and it will be tried`](#252-250-cannot-vrfy-user-send-the-message-and-it-will-be-tried)
55
+
56
+ **Messages**
57
+
58
+ - [`550 5.6.11 Bare CR or LF is not allowed in a message`](#550-5611-bare-cr-or-lf-is-not-allowed-in-a-message)
59
+ - [`552 5.3.4 Message too big for system`](#552-534-message-too-big-for-system)
60
+ - [`452 4.5.3 Too many recipients`](#452-453-too-many-recipients)
61
+ - [`554 5.5.1 No valid recipients`](#554-551-no-valid-recipients)
62
+ - [`SmtpError: The message is larger than maxMessageSize (… bytes); do not deliver it`](#smtperror-the-message-is-larger-than-maxmessagesize--bytes-do-not-deliver-it)
63
+ - [`SmtpError: The message holds a bare CR or LF (SMTP smuggling); do not deliver it`](#smtperror-the-message-holds-a-bare-cr-or-lf-smtp-smuggling-do-not-deliver-it)
64
+ - [`SmtpError: The connection closed before the end of the message; do not deliver it`](#smtperror-the-connection-closed-before-the-end-of-the-message-do-not-deliver-it)
65
+ - [`SmtpError: The connection closed before the reply to the message; do not deliver it`](#smtperror-the-connection-closed-before-the-reply-to-the-message-do-not-deliver-it)
66
+ - [`SmtpError: onData did not read the message within hookTimeout (… s); do not deliver it`](#smtperror-ondata-did-not-read-the-message-within-hooktimeout--s-do-not-deliver-it)
67
+ - [`SmtpError: onData did not answer within hookTimeout (… s); do not deliver it`](#smtperror-ondata-did-not-answer-within-hooktimeout--s-do-not-deliver-it)
68
+
69
+ **Hooks**
70
+
71
+ - [`451 4.3.0 Local error in processing`](#451-430-local-error-in-processing)
72
+ - [`SmtpError: … did not settle within hookTimeout (… s)`](#smtperror--did-not-settle-within-hooktimeout--s)
73
+ - [`SmtpError: … answered …, which is not a refusal: a hook refuses with a 4xx or 5xx reply, and accepts with undefined`](#smtperror--answered--which-is-not-a-refusal-a-hook-refuses-with-a-4xx-or-5xx-reply-and-accepts-with-undefined)
74
+ - [`SmtpError: onData answered without reading the message to its end; it was not taken`](#smtperror-ondata-answered-without-reading-the-message-to-its-end-it-was-not-taken)
75
+ - [`421 4.3.0 Local error, closing`](#421-430-local-error-closing)
76
+
77
+ **Connections**
78
+
79
+ - [`421 4.7.0 … Too many errors, closing`](#421-470--too-many-errors-closing)
80
+ - [`421 4.3.2 … Too many connections, try later`](#421-432--too-many-connections-try-later)
81
+ - [`421 4.4.2 … Idle too long, closing`](#421-442--idle-too-long-closing)
82
+ - [`554 … Talked before the greeting`](#554--talked-before-the-greeting)
83
+
84
+ ## Configuration
85
+
86
+ Each of these is thrown by `createSmtpServer(options)` itself, before
87
+ anything listens, as an `SmtpError` with `code: 'INVALID_OPTION'`:
88
+
89
+ ```ts
90
+ import { createSmtpServer, SmtpError, type SmtpServerOptions } from '@bumail/smtp';
91
+
92
+ const options: SmtpServerOptions = {
93
+ hostname: 'mx.example.com',
94
+ localDomains: ['example.com'],
95
+ onData: async (message) => {
96
+ await new Response(message.content).bytes();
97
+ },
98
+ };
99
+
100
+ try {
101
+ await createSmtpServer(options).listen({ port: 25 });
102
+ } catch (error) {
103
+ if (error instanceof SmtpError && error.code === 'INVALID_OPTION') console.error(error.message);
104
+ throw error;
105
+ }
106
+ ```
107
+
108
+ ### `SmtpError: createSmtpServer(): "…" is not a host name`
109
+
110
+ **When**: `hostname` is missing — the message then reads `"undefined" is
111
+ not a host name` — or holds anything but letters, digits, dots and
112
+ hyphens: an empty string, a port (`mx.example.com:25`), a URL, an
113
+ underscore, a space, or an address literal in brackets.
114
+
115
+ **Why**: `hostname` is the server's own name. It is written into the
116
+ greeting, the EHLO reply and every `Received` field, so it must be a plain
117
+ domain name. It is not the address to bind to.
118
+
119
+ **Fix**: give the name; bind with `listen`:
120
+
121
+ ```ts
122
+ import { createSmtpServer } from '@bumail/smtp';
123
+
124
+ const server = createSmtpServer({
125
+ hostname: 'mx.example.com',
126
+ localDomains: ['example.com'],
127
+ onData: async (message) => {
128
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
129
+ },
130
+ });
131
+ await server.listen({ port: 25, hostname: '0.0.0.0' });
132
+ ```
133
+
134
+ ### `SmtpError: createSmtpServer(): onData must be a function: it is where messages go`
135
+
136
+ **When**: no `onData`, or one that is not a function.
137
+
138
+ **Why**: an SMTP server that answers `250` has taken responsibility for the
139
+ message. Without `onData` it would have nowhere to put it.
140
+
141
+ **Fix**: give `onData`, and read the message to its end:
142
+
143
+ ```ts
144
+ import { createSmtpServer } from '@bumail/smtp';
145
+
146
+ createSmtpServer({
147
+ hostname: 'mx.example.com',
148
+ localDomains: ['example.com'],
149
+ async onData(message) {
150
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
151
+ },
152
+ });
153
+ ```
154
+
155
+ ### `SmtpError: createSmtpServer(): implicitTls needs tls: { key, cert }`
156
+
157
+ **When**: `implicitTls: true` without `tls`.
158
+
159
+ **Why**: implicit TLS (port 465, RFC 8314) starts TLS on the first byte,
160
+ so it needs a key and a certificate.
161
+
162
+ **Fix**:
163
+
164
+ ```ts
165
+ import { createSmtpServer } from '@bumail/smtp';
166
+
167
+ const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
168
+
169
+ createSmtpServer({
170
+ hostname: 'smtp.example.com',
171
+ mode: 'submission',
172
+ implicitTls: true,
173
+ tls: {
174
+ key: await Bun.file('/etc/ssl/smtp.example.com.key').text(),
175
+ cert: await Bun.file('/etc/ssl/smtp.example.com.crt').text(),
176
+ },
177
+ async authenticate({ username, password }) {
178
+ const hash = users.get(username);
179
+ return hash !== undefined && (await Bun.password.verify(password, hash));
180
+ },
181
+ localDomains: ['example.com'],
182
+ async onData(message) {
183
+ await Bun.write(`queue/${message.id}.eml`, await new Response(message.content).bytes());
184
+ },
185
+ });
186
+ ```
187
+
188
+ Leave `implicitTls` out on port 25 or 587: `tls` alone offers STARTTLS.
189
+
190
+ ### `SmtpError: createSmtpServer(): submission takes mail only from authenticated users, so it needs authenticate`
191
+
192
+ **When**: `mode: 'submission'` without `authenticate`.
193
+
194
+ **Why**: a submission server refuses `MAIL FROM` until the client has
195
+ authenticated. With nothing to check credentials, it could take no mail at
196
+ all.
197
+
198
+ **Fix**: give `authenticate`, and `tls` with it: AUTH is offered only over
199
+ TLS, so a submission server without `tls` takes no mail either. See the
200
+ snippet above. For mail from the Internet to your own domains, use
201
+ `mode: 'mx'` (the default) instead.
202
+
203
+ ### `SmtpError: createSmtpServer(): authenticate needs tls: { key, cert }, since AUTH is offered only once encrypted`
204
+
205
+ **When**: `authenticate` without `tls`.
206
+
207
+ **Why**: the server offers AUTH only over TLS, so a password never crosses
208
+ the network in clear. Without `tls`, no session could ever be encrypted,
209
+ and `authenticate` would never be called.
210
+
211
+ **Fix**: give `tls` with `authenticate`, as in the snippet above; or drop
212
+ `authenticate` if the server is an MX that takes no AUTH.
213
+
214
+ ### `SmtpError: createSmtpServer(): localDomains must be an array of domains or a function`
215
+
216
+ **When**: `localDomains` is missing, a single string (`'example.com'`), or
217
+ an array holding something other than strings.
218
+
219
+ **Why**: `localDomains` decides which recipients an unauthenticated
220
+ session may send to; the server never guesses it.
221
+
222
+ **Fix**: an array, even for one domain, or a function:
223
+
224
+ ```ts
225
+ import { createSmtpServer } from '@bumail/smtp';
226
+
227
+ createSmtpServer({
228
+ hostname: 'mx.example.com',
229
+ localDomains: ['example.com'], // not 'example.com'
230
+ onData: async (message) => {
231
+ await new Response(message.content).bytes();
232
+ },
233
+ });
234
+ ```
235
+
236
+ ### `SmtpError: createSmtpServer(): … must be a positive integer, not …`
237
+
238
+ **When**: `maxMessageSize`, `maxRecipients`, `maxConnections`, `maxErrors`,
239
+ `timeout` or `hookTimeout` is `0`, negative, a fraction, `NaN` or
240
+ `Infinity`.
241
+
242
+ **Why**: each is a bound on what a client can make the server do, and none
243
+ can be switched off.
244
+
245
+ **Fix**: pass integers, or leave an option out for its default:
246
+
247
+ ```ts
248
+ import { createSmtpServer } from '@bumail/smtp';
249
+
250
+ createSmtpServer({
251
+ hostname: 'mx.example.com',
252
+ localDomains: ['example.com'],
253
+ maxMessageSize: 50 * 1024 * 1024, // bytes; default 25 MiB
254
+ maxRecipients: 500, // default 100
255
+ maxConnections: 2000, // default 1000
256
+ maxErrors: 20, // default 10
257
+ timeout: 600, // seconds, not milliseconds; default 300
258
+ hookTimeout: 30, // seconds; default 60
259
+ onData: async (message) => {
260
+ await new Response(message.content).bytes();
261
+ },
262
+ });
263
+ ```
264
+
265
+ `timeout` and `hookTimeout` are in seconds: `300_000` is accepted, and
266
+ means 83 hours.
267
+
268
+ ### `SmtpError: createSmtpServer(): greetingDelay must be a number of seconds, 0 or more, not …`
269
+
270
+ **When**: `greetingDelay` is negative, `NaN` or `Infinity`.
271
+
272
+ **Why**: it is how long the server holds its 220 back, in seconds; `0`,
273
+ the default, sends the greeting at once. A fraction is accepted.
274
+
275
+ **Fix**: a few seconds, well below `timeout`, or leave it out:
276
+
277
+ ```ts
278
+ import { createSmtpServer } from '@bumail/smtp';
279
+
280
+ createSmtpServer({
281
+ hostname: 'mx.example.com',
282
+ localDomains: ['example.com'],
283
+ greetingDelay: 6, // seconds, not milliseconds
284
+ onData: async (message) => {
285
+ await new Response(message.content).bytes();
286
+ },
287
+ });
288
+ ```
289
+
290
+ ### `SmtpError: createSmtpServer(): greetingDelay (… s) must be shorter than timeout (… s), or every client times out before the greeting`
291
+
292
+ **When**: `greetingDelay` is as long as `timeout` or longer — `timeout`'s
293
+ default, 300, included.
294
+
295
+ **Why**: a client waiting for the 220 sends nothing, so the idle timer,
296
+ which runs from the connection, would run out first: every client would get
297
+ `421 4.4.2 … Idle too long, closing` and never a greeting. The bound covers
298
+ only that wait before the greeting: once the 220 is out, the idle time
299
+ starts again, so the client gets the whole `timeout` after it.
300
+
301
+ **Fix**: keep the delay to a few seconds, well below `timeout`:
302
+
303
+ ```ts
304
+ import { createSmtpServer } from '@bumail/smtp';
305
+
306
+ createSmtpServer({
307
+ hostname: 'mx.example.com',
308
+ localDomains: ['example.com'],
309
+ greetingDelay: 6,
310
+ timeout: 300,
311
+ onData: async (message) => {
312
+ await new Response(message.content).bytes();
313
+ },
314
+ });
315
+ ```
316
+
317
+ ### `SmtpError: listen(): the server is already listening on …`
318
+
319
+ **When**: `listen` on a server that is already listening. Its `code` is
320
+ `ALREADY_LISTENING`.
321
+
322
+ **Why**: a server holds one listener; a second would leave the first
323
+ unreachable by `stop`.
324
+
325
+ **Fix**: create one server per port, with the same options:
326
+
327
+ ```ts
328
+ import { createSmtpServer, type SmtpServerOptions } from '@bumail/smtp';
329
+
330
+ const options = {
331
+ hostname: 'mx.example.com',
332
+ localDomains: ['example.com'],
333
+ onData: async (message) => {
334
+ await new Response(message.content).bytes();
335
+ },
336
+ } satisfies SmtpServerOptions;
337
+
338
+ await createSmtpServer(options).listen({ port: 25 });
339
+ await createSmtpServer(options).listen({ port: 2525 });
340
+ ```
341
+
342
+ ## Relaying and authentication
343
+
344
+ ### `554 5.7.1 Relay access denied`
345
+
346
+ **When**: `RCPT TO` names an address whose domain is not in `localDomains`,
347
+ and the session has not authenticated. A `DATA` after it, with no other
348
+ recipient, gets `554 5.5.1 No valid recipients`. Each denial counts toward
349
+ `maxErrors`.
350
+
351
+ **Why**: the server is never an open relay. Without AUTH it takes mail only
352
+ for the domains it hosts, and no option or hook changes that: `onRcptTo`
353
+ runs after this check, and cannot accept a recipient it refused.
354
+
355
+ **Fix**, as the operator: list every domain you receive mail for. The
356
+ comparison ignores case and is exact: `example.com` does not cover
357
+ `mail.example.com`, so use a function to cover subdomains:
358
+
359
+ ```ts
360
+ import { createSmtpServer } from '@bumail/smtp';
361
+
362
+ createSmtpServer({
363
+ hostname: 'mx.example.com',
364
+ localDomains: (domain) =>
365
+ domain === 'example.com' || domain.endsWith('.example.com'),
366
+ onData: async (message) => {
367
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
368
+ },
369
+ });
370
+ ```
371
+
372
+ **Fix**, as a client: to send to another domain, authenticate first,
373
+ usually on the submission port (587 with STARTTLS, or 465). Port 25 of an
374
+ MX takes mail only for its own domains.
375
+
376
+ ### `AUTH` is missing from the EHLO reply
377
+
378
+ **When**: the `250-` lines answering `EHLO` list no `AUTH PLAIN LOGIN`, and
379
+ the client says the server does not support authentication. `AUTH` itself
380
+ gets `502 5.5.1 AUTH not available`, or
381
+ [`538 5.7.11`](#538-5711-encryption-required-for-requested-authentication-mechanism).
382
+
383
+ **Why**: `AUTH` is offered only when all three hold:
384
+
385
+ - the server was given `authenticate` (otherwise
386
+ [`502 5.5.1 AUTH not available`](#502-551-auth-not-available));
387
+ - the connection is encrypted: `implicitTls`, or after `STARTTLS`. On port
388
+ 25 or 587, the EHLO sent before STARTTLS never lists AUTH;
389
+ - the session has not authenticated yet.
390
+
391
+ Without `tls`, the server offers no `STARTTLS` either, and `STARTTLS` gets
392
+ `454 4.7.0 TLS not available`; and since `authenticate` needs `tls`, such a
393
+ server never offers AUTH.
394
+
395
+ **Fix**, as the operator: give both `tls` and `authenticate` (the server
396
+ refuses `authenticate` without `tls` at creation):
397
+
398
+ ```ts
399
+ import { createSmtpServer } from '@bumail/smtp';
400
+
401
+ const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
402
+
403
+ createSmtpServer({
404
+ hostname: 'smtp.example.com',
405
+ mode: 'submission',
406
+ tls: {
407
+ key: await Bun.file('/etc/ssl/smtp.example.com.key').text(),
408
+ cert: await Bun.file('/etc/ssl/smtp.example.com.crt').text(),
409
+ },
410
+ async authenticate({ username, password }) {
411
+ const hash = users.get(username);
412
+ return hash !== undefined && (await Bun.password.verify(password, hash));
413
+ },
414
+ localDomains: ['example.com'],
415
+ async onData(message) {
416
+ await Bun.write(`queue/${message.id}.eml`, await new Response(message.content).bytes());
417
+ },
418
+ });
419
+ ```
420
+
421
+ **Fix**, as a client: turn on STARTTLS (or implicit TLS on 465), and read
422
+ the EHLO reply that follows the TLS handshake, not the one before it.
423
+
424
+ ### `538 5.7.11 Encryption required for requested authentication mechanism`
425
+
426
+ **When**: `AUTH` on a connection that is not encrypted, on a server that
427
+ has `authenticate`.
428
+
429
+ **Why**: a password in clear can be read by anyone on the path. AUTH is
430
+ refused, and not advertised, before TLS (RFC 4954 §4).
431
+
432
+ **Fix**, as a client: `STARTTLS`, then `EHLO` again, then `AUTH`:
433
+
434
+ ```text
435
+ C: EHLO client.example.org
436
+ S: 250 STARTTLS
437
+ C: STARTTLS
438
+ S: 220 2.0.0 Ready to start TLS
439
+ (TLS handshake)
440
+ C: EHLO client.example.org
441
+ S: 250 AUTH PLAIN LOGIN
442
+ C: AUTH PLAIN <base64 of \0user\0password>
443
+ S: 235 2.7.0 Authentication successful
444
+ ```
445
+
446
+ **Fix**, as the operator: give `tls`, so the server can offer STARTTLS; see
447
+ [`AUTH` is missing from the EHLO reply](#auth-is-missing-from-the-ehlo-reply).
448
+
449
+ ### `530 5.7.0 Authentication required`
450
+
451
+ **When**: `MAIL FROM` before a successful `AUTH`, on a server in
452
+ `mode: 'submission'`.
453
+
454
+ **Why**: a submission server takes mail only from its own users (ports 587
455
+ and 465). It is not the server other MTAs deliver to.
456
+
457
+ **Fix**, as a client: authenticate after STARTTLS and EHLO, then send
458
+ `MAIL FROM`. Check that the client is set to use AUTH, and that it is
459
+ talking to the submission port, not to the MX.
460
+
461
+ **Fix**, as the operator: to receive mail from the Internet for your own
462
+ domains, run a second server in `mode: 'mx'` (the default) on port 25.
463
+
464
+ ### `535 5.7.8 Authentication credentials invalid`
465
+
466
+ **When**: `AUTH PLAIN` or `AUTH LOGIN`, when `authenticate` answered
467
+ anything but `true`; or `AUTH PLAIN` with an authorization identity other
468
+ than the username, refused before `authenticate` is asked. Each one counts
469
+ toward `maxErrors`, and the third in a session
470
+ [closes it](#421-470--too-many-failed-authentications-closing).
471
+
472
+ **Why**: the server accepts the credentials only when `authenticate`
473
+ returns, or resolves to, exactly `true`. A user object, a non-empty string
474
+ or `1` counts as a refusal.
475
+
476
+ **Fix**, as the operator: return a boolean:
477
+
478
+ ```ts
479
+ import { createSmtpServer } from '@bumail/smtp';
480
+
481
+ const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
482
+
483
+ createSmtpServer({
484
+ hostname: 'smtp.example.com',
485
+ mode: 'submission',
486
+ localDomains: ['example.com'],
487
+ tls: {
488
+ key: await Bun.file('/etc/ssl/smtp.example.com.key').text(),
489
+ cert: await Bun.file('/etc/ssl/smtp.example.com.crt').text(),
490
+ },
491
+ async authenticate({ username, password }) {
492
+ const hash = users.get(username);
493
+ return hash !== undefined && (await Bun.password.verify(password, hash)); // a boolean
494
+ },
495
+ onData: async (message) => {
496
+ await new Response(message.content).bytes();
497
+ },
498
+ });
499
+ ```
500
+
501
+ A session acts as the user it authenticated as: there is no option to let
502
+ one user act as another through PLAIN's authorization identity.
503
+
504
+ **Fix**, as a client: check the username and password, and send no
505
+ authorization identity — or the username itself.
506
+
507
+ ### `454 4.7.0 Temporary authentication failure`
508
+
509
+ **When**: `AUTH`, when `authenticate` threw, its promise rejected, or it
510
+ did not settle within `hookTimeout`. It does not count as a failed attempt,
511
+ nor toward `maxErrors`.
512
+
513
+ **Why**: an error in the check is the server's failure, not the client's
514
+ (RFC 4954 §6), so the client is told to try again later. The error goes to
515
+ `onError`.
516
+
517
+ **Fix**, as the operator: log in `onError` to see why, then fix the store
518
+ `authenticate` reads:
519
+
520
+ ```ts
521
+ import { createSmtpServer } from '@bumail/smtp';
522
+
523
+ createSmtpServer({
524
+ hostname: 'smtp.example.com',
525
+ mode: 'submission',
526
+ localDomains: ['example.com'],
527
+ tls: {
528
+ key: await Bun.file('/etc/ssl/smtp.example.com.key').text(),
529
+ cert: await Bun.file('/etc/ssl/smtp.example.com.crt').text(),
530
+ },
531
+ authenticate: async ({ username, password }) => {
532
+ const response = await fetch('https://directory.example.com/check', {
533
+ method: 'POST',
534
+ body: JSON.stringify({ username, password }),
535
+ });
536
+ return response.ok;
537
+ },
538
+ onError: (error, session) => console.error(`[${session.id}]`, error),
539
+ onData: async (message) => {
540
+ await new Response(message.content).bytes();
541
+ },
542
+ });
543
+ ```
544
+
545
+ **Fix**, as a client: retry later.
546
+
547
+ ### `421 4.7.0 … Too many failed authentications, closing`
548
+
549
+ **When**: the third `535 5.7.8` in one session. The server hangs up. `…`
550
+ is the server's `hostname`.
551
+
552
+ **Why**: it bounds password guessing on one connection. The limit is
553
+ three, and is not an option. A response that is not valid base64 gets
554
+ `501 5.5.2 Cannot decode the response` and does not count.
555
+
556
+ **Fix**, as a client: fix the credentials, then connect again.
557
+
558
+ **Fix**, as the operator: see
559
+ [`535 5.7.8`](#535-578-authentication-credentials-invalid): a check that
560
+ never answers `true` refuses everyone.
561
+
562
+ ### `501 5.5.2 Cannot decode the response`
563
+
564
+ **When**: the response to `AUTH`, or to its `334` challenge, is not valid
565
+ base64, not UTF-8, or (for PLAIN) not `authzid NUL user NUL password` with
566
+ a username and a password. It counts toward `maxErrors`, not toward the
567
+ three AUTH attempts.
568
+
569
+ **Why**: the server reads only what RFC 4954 and RFC 4616 define.
570
+
571
+ **Fix**, as a client: base64-encode the whole response, NULs included:
572
+
573
+ ```ts
574
+ const response = new TextEncoder().encode('\0alice\0correct horse').toBase64();
575
+ console.log(`AUTH PLAIN ${response}`); // AUTH PLAIN AGFsaWNlAGNvcnJlY3QgaG9yc2U=
576
+ ```
577
+
578
+ A client answering a challenge with `*` cancels instead, and gets
579
+ [`501 5.0.0 Authentication cancelled`](#501-500-authentication-cancelled).
580
+
581
+ ### `504 5.5.4 Unrecognized authentication type`
582
+
583
+ **When**: `AUTH` with a mechanism other than `PLAIN` and `LOGIN`, such as
584
+ `CRAM-MD5`, `SCRAM-SHA-256` or `XOAUTH2`.
585
+
586
+ **Why**: the server implements PLAIN and LOGIN only, over TLS.
587
+
588
+ **Fix**, as a client: pick `PLAIN` or `LOGIN`, as the EHLO reply's
589
+ `AUTH PLAIN LOGIN` line lists.
590
+
591
+ ### `502 5.5.1 AUTH not available`
592
+
593
+ **When**: `AUTH` on a server created without `authenticate`.
594
+
595
+ **Why**: such a server has no way to check credentials, and never offers
596
+ AUTH. An MX usually has none.
597
+
598
+ **Fix**, as a client: send to the submission server, not to the MX.
599
+ **Fix**, as the operator: give `authenticate` and `tls`; see
600
+ [`AUTH` is missing from the EHLO reply](#auth-is-missing-from-the-ehlo-reply).
601
+
602
+ ### `503 5.5.1 Already authenticated`
603
+
604
+ **When**: `AUTH` in a session that already authenticated. It counts toward
605
+ `maxErrors`.
606
+
607
+ **Why**: a session acts as one user (RFC 4954 §4); once AUTH succeeded the
608
+ server no longer advertises it.
609
+
610
+ **Fix**, as a client: authenticate once per connection; to act as another
611
+ user, `QUIT` and connect again.
612
+
613
+ ### `503 5.5.1 AUTH not allowed during a transaction`
614
+
615
+ **When**: `AUTH` after `MAIL FROM` was accepted and before the end of
616
+ `DATA` or an `RSET`. It counts toward `maxErrors`.
617
+
618
+ **Why**: RFC 4954 §4: AUTH is not allowed in the middle of a mail
619
+ transaction, whose sender was already checked as the session stood.
620
+
621
+ **Fix**, as a client: authenticate before `MAIL FROM`, or send `RSET`
622
+ first:
623
+
624
+ ```text
625
+ C: RSET
626
+ S: 250 2.0.0 OK
627
+ C: AUTH PLAIN AGFsaWNlAGNvcnJlY3QgaG9yc2U=
628
+ S: 235 2.7.0 Authentication successful
629
+ ```
630
+
631
+ ### `501 5.0.0 Authentication cancelled`
632
+
633
+ **When**: the client answered a `334` challenge of `AUTH` with `*`. It
634
+ counts toward `maxErrors`, not toward the three AUTH attempts.
635
+
636
+ **Why**: RFC 4954 §4: `*` is how a client gives up an exchange; the server
637
+ confirms it and the session goes on unauthenticated.
638
+
639
+ **Fix**, as a client: nothing, if it meant to cancel. Otherwise send the
640
+ base64 response to the challenge — see
641
+ [`501 5.5.2 Cannot decode the response`](#501-552-cannot-decode-the-response).
642
+
643
+ ## Commands
644
+
645
+ ### `501 Syntax: EHLO hostname`
646
+
647
+ **When**: `EHLO` with no argument, or with one that is neither a domain nor
648
+ an address literal — an underscore, a space, a bare IP without brackets.
649
+ `HELO` gets `501 Syntax: HELO hostname`. Before an EHLO was accepted the
650
+ reply has no enhanced code; after one, it is
651
+ `501 5.5.4 Syntax: EHLO hostname`. It counts toward `maxErrors`.
652
+
653
+ **Why**: RFC 5321 §4.1.1.1: the argument is the client's own domain, or its
654
+ address in brackets, and it goes into the `Received` field.
655
+
656
+ **Fix**, as a client: send the client's fully qualified name, or its
657
+ address literal:
658
+
659
+ ```text
660
+ C: EHLO client.example.org
661
+ C: EHLO [192.0.2.1]
662
+ C: EHLO [IPv6:2001:db8::1]
663
+ ```
664
+
665
+ ### `503 Send EHLO first`
666
+
667
+ **When**:
668
+
669
+ - `MAIL FROM` before `EHLO` or `HELO`;
670
+ - `MAIL FROM` or `AUTH` after `STARTTLS`, before a new `EHLO`;
671
+ - `AUTH` after `HELO`: AUTH needs `EHLO`.
672
+
673
+ It never carries an enhanced status code, since no EHLO is in effect when
674
+ it is sent.
675
+
676
+ **Why**: STARTTLS starts the session over (RFC 3207 §4.2): the greeting,
677
+ the EHLO and any transaction from before TLS are forgotten.
678
+
679
+ **Fix**, as a client: send `EHLO` after connecting and again after the TLS
680
+ handshake. Every common client library does; a hand-written client must:
681
+
682
+ ```text
683
+ C: STARTTLS
684
+ S: 220 2.0.0 Ready to start TLS
685
+ (TLS handshake)
686
+ C: EHLO client.example.org
687
+ ```
688
+
689
+ The same lack of EHLO turns a bad `EHLO` or `HELO` argument into
690
+ [`501 Syntax: EHLO hostname`](#501-syntax-ehlo-hostname), also without an
691
+ enhanced code.
692
+
693
+ ### `503 5.5.1 Send MAIL first`, `503 5.5.1 Nested MAIL command`
694
+
695
+ **When**: `RCPT TO` or `DATA` before a `MAIL FROM` was accepted; or a
696
+ second `MAIL FROM` while a transaction is open. Each counts toward
697
+ `maxErrors`.
698
+
699
+ **Why**: RFC 5321 §4.1.4 orders a transaction: one MAIL, one or more RCPT,
700
+ then DATA. A `MAIL FROM` refused by the server or by `onMailFrom` opens no
701
+ transaction, so the RCPT after it gets this reply.
702
+
703
+ **Fix**, as a client: read the reply to `MAIL FROM` before sending RCPT;
704
+ send `RSET` to start a transaction over.
705
+
706
+ ### `501 5.5.4 Syntax: …`
707
+
708
+ **When**: a command whose argument is wrong: `MAIL FROM:<address>` without
709
+ the angle brackets or the colon, a path with a space, a control or an
710
+ invisible character (C1, zero-width, bidi, U+2028), `DATA` or `STARTTLS`
711
+ with an argument, `AUTH` with more than a mechanism and a response,
712
+ `SIZE=` that is not a number. The text after `Syntax:` gives the expected
713
+ form. Each counts toward `maxErrors`.
714
+
715
+ **Why**: the server reads RFC 5321's grammar exactly, so that an address is
716
+ never read two ways.
717
+
718
+ **Fix**, as a client: send the form the reply gives:
719
+
720
+ ```text
721
+ C: MAIL FROM:<alice@example.com> SIZE=1024
722
+ S: 250 2.1.0 OK
723
+ ```
724
+
725
+ ### `500 5.5.2 Command unrecognized`
726
+
727
+ **When**: a verb the server does not implement — `EXPN`, `BDAT`, `TURN`,
728
+ `ETRN`, or a typo. It counts toward `maxErrors`.
729
+
730
+ **Why**: the server implements RFC 5321's commands and the extensions its
731
+ EHLO lists. `EXPN` gives away a list's members; `BDAT` is CHUNKING, not
732
+ offered.
733
+
734
+ **Fix**, as a client: use only what the EHLO reply announces; send `DATA`
735
+ rather than `BDAT`.
736
+
737
+ ### `454 4.7.0 TLS not available`
738
+
739
+ **When**: `STARTTLS` on a server created without `tls`.
740
+
741
+ **Why**: with no key and certificate, the server cannot encrypt; it does
742
+ not list `STARTTLS` in its EHLO reply either.
743
+
744
+ **Fix**, as the operator: give `tls` (and `STARTTLS` with an argument gets
745
+ `501 5.5.4 Syntax: STARTTLS`; an encrypted session, `503 5.5.1 TLS already
746
+ active`):
747
+
748
+ ```ts
749
+ import { createSmtpServer } from '@bumail/smtp';
750
+
751
+ createSmtpServer({
752
+ hostname: 'mx.example.com',
753
+ localDomains: ['example.com'],
754
+ tls: {
755
+ key: await Bun.file('/etc/ssl/mx.example.com.key').text(),
756
+ cert: await Bun.file('/etc/ssl/mx.example.com.crt').text(),
757
+ },
758
+ onData: async (message) => {
759
+ await new Response(message.content).bytes();
760
+ },
761
+ });
762
+ ```
763
+
764
+ **Fix**, as a client: send in clear, or to a server that offers TLS.
765
+
766
+ ### `503 5.5.1 TLS already active`
767
+
768
+ **When**: `STARTTLS` on a connection that is already encrypted — after a
769
+ first STARTTLS, or on an `implicitTls` server (port 465). It counts toward
770
+ `maxErrors`.
771
+
772
+ **Why**: RFC 3207 §4: TLS is started once per connection. An encrypted
773
+ session's EHLO reply does not list `STARTTLS`.
774
+
775
+ **Fix**, as a client: send STARTTLS only when the EHLO reply lists it. On
776
+ port 465, set the client to implicit TLS (often called "SSL/TLS"), not
777
+ STARTTLS.
778
+
779
+ ### `501 5.5.4 BODY is 7BIT or 8BITMIME`
780
+
781
+ **When**: `MAIL FROM:<…> BODY=` with a value other than `7BIT` or
782
+ `8BITMIME`, such as `BINARYMIME`. It counts toward `maxErrors`.
783
+
784
+ **Why**: the server announces `8BITMIME` (RFC 6152), not `BINARYMIME`
785
+ (RFC 3030), which needs CHUNKING.
786
+
787
+ **Fix**, as a client: send `BODY=8BITMIME` for 8-bit content, or no `BODY`
788
+ at all:
789
+
790
+ ```text
791
+ C: MAIL FROM:<alice@example.com> BODY=8BITMIME
792
+ S: 250 2.1.0 OK
793
+ ```
794
+
795
+ ### `555 5.5.4 … is not supported`
796
+
797
+ **When**: `MAIL FROM` with a parameter other than `SIZE`, `BODY`,
798
+ `SMTPUTF8` and `AUTH`, such as `RET` or `ENVID`; or `RCPT TO` with any
799
+ parameter, such as `NOTIFY` or `ORCPT`; or `SMTPUTF8` given a value.
800
+
801
+ **Why**: the server supports only the extensions its EHLO lists. Delivery
802
+ status notifications (DSN, RFC 3461) are not among them.
803
+
804
+ **Fix**, as a client: send only the parameters the EHLO reply announces;
805
+ turn off DSN options (`NOTIFY`, `RET`, `ENVID`, `ORCPT`) for this server.
806
+
807
+ ### `555 … needs EHLO`
808
+
809
+ **When**: any `MAIL FROM` parameter, such as `SIZE=` or `BODY=`, after
810
+ `HELO`.
811
+
812
+ **Why**: parameters are ESMTP (RFC 5321 §4.1.2); a client that said `HELO`
813
+ declared it does not speak it.
814
+
815
+ **Fix**, as a client: greet with `EHLO`, or send no parameters.
816
+
817
+ ### `553 5.6.7 A non-ASCII address needs SMTPUTF8`
818
+
819
+ **When**: `MAIL FROM` or `RCPT TO` with an address holding a non-ASCII
820
+ character, such as `jörg@example.com`, when the `MAIL FROM` of the
821
+ transaction did not carry `SMTPUTF8`.
822
+
823
+ **Why**: RFC 6531 lets a UTF-8 address through only once the client has
824
+ said it will send one, so that a server down the line that cannot take it
825
+ is never handed one.
826
+
827
+ **Fix**, as a client: add `SMTPUTF8` to `MAIL FROM` (after `EHLO`, which
828
+ lists it):
829
+
830
+ ```text
831
+ C: MAIL FROM:<jörg@example.com> SMTPUTF8
832
+ S: 250 2.1.0 OK
833
+ ```
834
+
835
+ ### `500 5.5.6 Line too long`
836
+
837
+ **When**: a command line longer than 2048 bytes. The rest of that line is
838
+ skipped, and the failure counts toward `maxErrors`. Message content after
839
+ `DATA` has no such limit.
840
+
841
+ **Why**: the server holds a command line in memory before reading it, so
842
+ the line is bounded. RFC 5321 §4.5.3.1.4 asks for 512 bytes at least.
843
+
844
+ **Fix**, as a client: keep commands short. The usual cause is message
845
+ content sent as commands: a client that pipelines the message after a
846
+ `DATA` that was refused, such as `554 5.5.1 No valid recipients`. Wait for
847
+ `354` before sending the message.
848
+
849
+ ### `252 2.5.0 Cannot VRFY user; send the message and it will be tried`
850
+
851
+ **When**: `VRFY`, whatever its argument. It is not an error, and does not
852
+ count toward `maxErrors`.
853
+
854
+ **Why**: confirming or denying a mailbox lets anyone harvest addresses, so
855
+ the server answers neither (RFC 5321 §3.5.3); `252` says the address may
856
+ still be valid. There is no option to answer `VRFY`.
857
+
858
+ **Fix**, as a client: send the message; a recipient that does not exist is
859
+ refused at `RCPT TO` — by `onRcptTo`, if the operator gave one.
860
+
861
+ ## Messages
862
+
863
+ ### `550 5.6.11 Bare CR or LF is not allowed in a message`
864
+
865
+ **When**: during `DATA`, as soon as the message holds a CR or an LF that is
866
+ not part of a CRLF pair. `message.content` errors with an `SmtpError` of
867
+ code `BARE_LINE_BREAK`, so `onData` never reads it to a clean end; the rest
868
+ of the message is read and dropped, and the session goes on.
869
+
870
+ **Why**: a bare line break is how SMTP smuggling hides a second message
871
+ inside the first: servers that read line ends differently disagree where
872
+ the message ends. The server refuses such a message rather than guess.
873
+
874
+ **Fix**, as a client: send every line ending as CRLF, headers and body,
875
+ and encode binary content (base64 or quoted-printable). There is no
876
+ option to accept it.
877
+
878
+ ### `552 5.3.4 Message too big for system`
879
+
880
+ **When**:
881
+
882
+ - `MAIL FROM:<…> SIZE=n` with `n` above `maxMessageSize`: refused at once;
883
+ - during `DATA`, once the message is larger than `maxMessageSize`.
884
+ `message.content` errors with an `SmtpError` of code `MESSAGE_TOO_BIG`;
885
+ the rest of the message is read and dropped, and the session goes on.
886
+
887
+ **Why**: `maxMessageSize` (25 MiB by default) bounds what one message can
888
+ cost. The EHLO reply announces it as `SIZE n`. The size is counted after
889
+ dot-unstuffing, without the server's own `Received` field.
890
+
891
+ **Fix**, as the operator: raise the limit:
892
+
893
+ ```ts
894
+ import { createSmtpServer } from '@bumail/smtp';
895
+
896
+ createSmtpServer({
897
+ hostname: 'mx.example.com',
898
+ localDomains: ['example.com'],
899
+ maxMessageSize: 50 * 1024 * 1024,
900
+ onData: async (message) => {
901
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
902
+ },
903
+ });
904
+ ```
905
+
906
+ **Fix**, as a client: read `SIZE` from the EHLO reply and send smaller
907
+ messages, such as a link instead of a large attachment. Base64 makes an
908
+ attachment about a third larger than the file.
909
+
910
+ ### `452 4.5.3 Too many recipients`
911
+
912
+ **When**: `RCPT TO` past `maxRecipients` in one transaction (100 by
913
+ default). The recipients accepted before it stay accepted, and it does not
914
+ count toward `maxErrors`.
915
+
916
+ **Why**: RFC 5321 §4.5.3.1.10: a server bounds recipients per message, and
917
+ a 452 tells the client to send the rest in another transaction.
918
+
919
+ **Fix**, as a client: send `DATA` for the recipients accepted, then the
920
+ same message again in a new transaction for the rest. Most MTAs and client
921
+ libraries do it on their own.
922
+
923
+ **Fix**, as the operator: raise `maxRecipients`. Below 100, some clients
924
+ will not split their recipients and fail instead.
925
+
926
+ ### `554 5.5.1 No valid recipients`
927
+
928
+ **When**: `DATA` with no recipient accepted. Whatever the client pipelined
929
+ behind `DATA` in the same write — the message itself, usually — is dropped,
930
+ never read as commands.
931
+
932
+ **Why**: there is no one to deliver to: each `RCPT TO` was refused (relay,
933
+ `onRcptTo`, syntax) or none was sent.
934
+
935
+ **Fix**, as a client: read the replies to `RCPT TO`; send `DATA` only once
936
+ one was `250`.
937
+
938
+ ### `SmtpError: The message is larger than maxMessageSize (… bytes); do not deliver it`
939
+
940
+ **When**: `onData` reads `message.content` and the read throws this
941
+ `SmtpError`, code `MESSAGE_TOO_BIG`. The client got
942
+ [`552 5.3.4 Message too big for system`](#552-534-message-too-big-for-system).
943
+
944
+ **Why**: the server hands the message over while it arrives, so it can
945
+ only say at the end whether the message may be kept. The stream's error is
946
+ that answer; the client is refused whatever `onData` returns.
947
+
948
+ **Fix**: let the error propagate out of `onData`, and remove whatever you
949
+ wrote before it:
950
+
951
+ ```ts
952
+ import { createSmtpServer } from '@bumail/smtp';
953
+
954
+ createSmtpServer({
955
+ hostname: 'mx.example.com',
956
+ localDomains: ['example.com'],
957
+ async onData(message) {
958
+ const path = `spool/${message.id}.eml`;
959
+ try {
960
+ await Bun.write(path, new Response(message.content));
961
+ } catch (error) {
962
+ await Bun.file(path).delete().catch(() => undefined);
963
+ throw error;
964
+ }
965
+ },
966
+ });
967
+ ```
968
+
969
+ To take larger messages, raise `maxMessageSize`.
970
+
971
+ ### `SmtpError: The message holds a bare CR or LF (SMTP smuggling); do not deliver it`
972
+
973
+ **When**: the read of `message.content` throws this `SmtpError`, code
974
+ `BARE_LINE_BREAK`. The client got
975
+ [`550 5.6.11 Bare CR or LF is not allowed in a message`](#550-5611-bare-cr-or-lf-is-not-allowed-in-a-message).
976
+
977
+ **Why**: the message may hide a second one; it is refused whole.
978
+
979
+ **Fix**: delete what you wrote and let the error propagate, as in
980
+ [the entry above](#smtperror-the-message-is-larger-than-maxmessagesize--bytes-do-not-deliver-it).
981
+
982
+ ### `SmtpError: The connection closed before the end of the message; do not deliver it`
983
+
984
+ **When**: the read of `message.content` throws this `SmtpError`, code
985
+ `CONNECTION_LOST`: the connection closed during `DATA` — the client hung
986
+ up, or the server closed it on its idle `timeout` or a socket error.
987
+
988
+ **Why**: a message cut short is not the message the client meant to send.
989
+ It sends it again on its next attempt.
990
+
991
+ **Fix**: delete what you wrote and let the error propagate, as in
992
+ [the entry above](#smtperror-the-message-is-larger-than-maxmessagesize--bytes-do-not-deliver-it).
993
+
994
+ ### `SmtpError: The connection closed before the reply to the message; do not deliver it`
995
+
996
+ **When**: `message.signal` aborts with this `SmtpError`, code
997
+ `CONNECTION_LOST`: the whole message came, but the connection closed
998
+ while `onData` was still answering — the client hung up, or the server
999
+ closed it on its idle `timeout` or a socket error — so the client never
1000
+ heard a reply. The stream may
1001
+ already have ended cleanly; if `onData` was still reading, the read throws
1002
+ it too.
1003
+
1004
+ **Why**: a client that heard no `250` will send the message again, so
1005
+ keeping this copy would deliver it twice. A client that leaves once the
1006
+ reply was sent does not abort the signal. An idle `timeout` shorter than
1007
+ the time `onData` takes to answer closes the connection itself, so keep it
1008
+ well above `hookTimeout` (300 s against 60 s by default).
1009
+
1010
+ **Fix**: check `message.signal` before keeping the message for good, as in
1011
+ [the `onData did not answer` entry](#smtperror-ondata-did-not-answer-within-hooktimeout--s-do-not-deliver-it).
1012
+
1013
+ ### `SmtpError: onData did not read the message within hookTimeout (… s); do not deliver it`
1014
+
1015
+ **When**: the read of `message.content` throws this `SmtpError`, code
1016
+ `HOOK_TIMEOUT`: `onData` read nothing for `hookTimeout` seconds while the
1017
+ client still had message to send. The client got
1018
+ `451 4.3.0 Local error in processing`, and `onError` was given
1019
+ [`SmtpError: onData did not settle within hookTimeout (… s)`](#smtperror--did-not-settle-within-hooktimeout--s).
1020
+
1021
+ **Why**: the server holds 64 KiB of a message at most, and stops reading
1022
+ the client until `onData` reads; an `onData` that stopped reading would
1023
+ hold the connection forever.
1024
+
1025
+ **Fix**: read the stream as it comes, and do the slow work after it ended:
1026
+ write the bytes to disk first, then parse, scan or forward them.
1027
+
1028
+ ### `SmtpError: onData did not answer within hookTimeout (… s); do not deliver it`
1029
+
1030
+ **When**: the read of `message.content` throws this `SmtpError`, code
1031
+ `HOOK_TIMEOUT`, in an `onData` that read past the end of DATA too late:
1032
+ the client had sent the whole message, and `onData` neither read it to
1033
+ the end nor answered within `hookTimeout` seconds. The client got
1034
+ `451 4.3.0 Local error in processing`, and `onError` was given
1035
+ [`SmtpError: onData did not settle within hookTimeout (… s)`](#smtperror--did-not-settle-within-hooktimeout--s).
1036
+
1037
+ **Why**: the client was told 451 and will send the message again; a read
1038
+ that ended cleanly after that would deliver it twice.
1039
+
1040
+ **Fix**: read the stream inside `onData`, and before keeping the message
1041
+ check `message.signal`: it aborts whenever the server refuses the message
1042
+ on `onData`'s behalf, including when the read had already ended cleanly
1043
+ and only the answer was late. Or raise `hookTimeout` above what the slow work takes:
1044
+
1045
+ ```ts
1046
+ import { createSmtpServer } from '@bumail/smtp';
1047
+
1048
+ createSmtpServer({
1049
+ hostname: 'mx.example.com',
1050
+ localDomains: ['example.com'],
1051
+ hookTimeout: 120,
1052
+ async onData(message) {
1053
+ const bytes = await new Response(message.content).bytes();
1054
+ await fetch('http://127.0.0.1:3310/scan', { method: 'POST', body: bytes });
1055
+ if (message.signal.aborted) return; // refused: the client will send it again
1056
+ await Bun.write(`spool/${message.id}.eml`, bytes);
1057
+ },
1058
+ });
1059
+ ```
1060
+
1061
+ A check just before the commit narrows the race; RFC 5321 §6.1 prefers a
1062
+ duplicate to a loss, so what is left costs a second copy, never a message.
1063
+
1064
+ ## Hooks
1065
+
1066
+ ### `451 4.3.0 Local error in processing`
1067
+
1068
+ **When**: one of the hooks threw, its promise rejected, it did not settle
1069
+ within `hookTimeout`, or it answered a reply under 400; `localDomains`
1070
+ threw or did not settle; `onData` stopped reading the message for
1071
+ `hookTimeout` seconds; or `onData` resolved without a refusal before it
1072
+ read the message to its end, or cancelled the stream:
1073
+
1074
+ - `onConnect`: the connection gets `451 Local error in processing` in place
1075
+ of its greeting, without an enhanced code, and is closed;
1076
+ - `onMailFrom`, `onRcptTo`, `localDomains`: that command is refused, and
1077
+ the session goes on;
1078
+ - `onData`: the message is refused, and the client keeps it. The `250`
1079
+ goes out only when `onData` read `message.content` to its clean end.
1080
+
1081
+ **Why**: a hook that fails has neither accepted nor refused, so the server
1082
+ answers with a temporary failure: a client tries again later rather than
1083
+ lose the message. The cause goes to `onError`.
1084
+
1085
+ **Fix**, as the operator: log in `onError`, then fix the hook:
1086
+
1087
+ ```ts
1088
+ import { createSmtpServer } from '@bumail/smtp';
1089
+
1090
+ createSmtpServer({
1091
+ hostname: 'mx.example.com',
1092
+ localDomains: ['example.com'],
1093
+ onData: async (message) => {
1094
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
1095
+ },
1096
+ onError: (error, session) => console.error(`[${session.id}]`, error),
1097
+ });
1098
+ ```
1099
+
1100
+ **Fix**, as a client: retry later. An MTA queues the message and does so on
1101
+ its own.
1102
+
1103
+ ### `SmtpError: … did not settle within hookTimeout (… s)`
1104
+
1105
+ **When**: `onError` gets an `SmtpError` of code `HOOK_TIMEOUT`, its message
1106
+ naming what ran late: `onConnect`, `onMailFrom`, `onRcptTo`, `onData`,
1107
+ `authenticate` or `localDomains` did not settle within `hookTimeout`
1108
+ seconds (60 by default) — or `onData` stopped reading the message for that
1109
+ long, in which case its stream also ends in
1110
+ [`SmtpError: onData did not read the message within hookTimeout (… s); do not deliver it`](#smtperror-ondata-did-not-read-the-message-within-hooktimeout--s-do-not-deliver-it).
1111
+ When `onData` itself did not answer in time, its stream ends in
1112
+ [`SmtpError: onData did not answer within hookTimeout (… s); do not deliver it`](#smtperror-ondata-did-not-answer-within-hooktimeout--s-do-not-deliver-it),
1113
+ so a read still running then keeps nothing. A read that had already reached
1114
+ the end before the timeout is not undone: if `onData` read the whole message
1115
+ but never answered, the client was told `451` and will send it again.
1116
+ `message.signal` aborts in that case too; check it before keeping the
1117
+ message.
1118
+ The client got `451 4.3.0` (or `454 4.7.0` for `authenticate`).
1119
+
1120
+ **Why**: while a hook runs, the session waits; a hook that never settles
1121
+ would hold the connection, and the message, forever.
1122
+
1123
+ **Fix**: put a deadline on what the hook awaits, shorter than
1124
+ `hookTimeout`, or raise `hookTimeout`:
1125
+
1126
+ ```ts
1127
+ import { createSmtpServer, reply } from '@bumail/smtp';
1128
+
1129
+ createSmtpServer({
1130
+ hostname: 'mx.example.com',
1131
+ localDomains: ['example.com'],
1132
+ hookTimeout: 120,
1133
+ onRcptTo: async (path) => {
1134
+ const response = await fetch(`https://directory.example.com/${encodeURIComponent(path.local)}`, {
1135
+ signal: AbortSignal.timeout(10_000), // throws first: 451, and onError gets the AbortError
1136
+ });
1137
+ return response.ok ? undefined : reply(550, '5.1.1', 'No such user here');
1138
+ },
1139
+ onData: async (message) => {
1140
+ await new Response(message.content).bytes();
1141
+ },
1142
+ });
1143
+ ```
1144
+
1145
+ ### `SmtpError: … answered …, which is not a refusal: a hook refuses with a 4xx or 5xx reply, and accepts with undefined`
1146
+
1147
+ **When**: `onError` gets an `SmtpError` of code `INVALID_HOOK_REPLY`, its
1148
+ message naming the hook and what it returned as JSON: a hook returned something other than `undefined` or a `Reply` with a code
1149
+ from 400 to 599 — a `250`, a string, a number. The client got `451 4.3.0`.
1150
+
1151
+ **Why**: a hook accepts by returning nothing. Sending a `250` from a hook
1152
+ would tell the client yes while the server did not take the command.
1153
+
1154
+ **Fix**: return `undefined` to accept, `reply(4xx|5xx, …)` to refuse:
1155
+
1156
+ ```ts
1157
+ import { createSmtpServer, reply } from '@bumail/smtp';
1158
+
1159
+ createSmtpServer({
1160
+ hostname: 'mx.example.com',
1161
+ localDomains: ['example.com'],
1162
+ onMailFrom: (path) =>
1163
+ path.domain === 'spam.example' ? reply(550, '5.7.1', 'Sender refused') : undefined,
1164
+ onData: async (message) => {
1165
+ await new Response(message.content).bytes();
1166
+ },
1167
+ });
1168
+ ```
1169
+
1170
+ ### `SmtpError: onData answered without reading the message to its end; it was not taken`
1171
+
1172
+ **When**: `onError` gets this `SmtpError`, code `MESSAGE_NOT_READ`: `onData`
1173
+ resolved without a refusal before it read `message.content` to its end, or
1174
+ cancelled the stream. A read it left running counts as not read: the
1175
+ stream errors with this same error the moment `onData` answers, so that
1176
+ read never reaches a clean end. The client got `451 4.3.0 Local error in
1177
+ processing`, never `250`, keeps the message, and `message.signal` aborts
1178
+ with this error. An `onData` that refuses before reading gets its own
1179
+ refusal sent, its signal left alone and nothing reported; only a read it
1180
+ left running ends in this error. That holds unless the client never hears
1181
+ the refusal: a later stream failure (whose 552 or 550 replaces
1182
+ `onData`'s reply) or a closed connection still aborts the signal, with
1183
+ that error.
1184
+
1185
+ **Why**: a `250` makes the server responsible for the message. An `onData`
1186
+ that stopped early has stored part of it at most, so the server does not
1187
+ claim it took it.
1188
+
1189
+ **Fix**: read the stream to its end before returning — or refuse with a
1190
+ `Reply`, which needs no reading and leaves `message.signal` alone unless
1191
+ a later stream failure replaces that reply or the connection closes
1192
+ before it is sent, which abort it instead:
1193
+
1194
+ ```ts
1195
+ import { createSmtpServer, reply } from '@bumail/smtp';
1196
+
1197
+ createSmtpServer({
1198
+ hostname: 'mx.example.com',
1199
+ localDomains: ['example.com'],
1200
+ async onData(message) {
1201
+ if (message.envelope.from === '') return reply(550, '5.7.1', 'No bounces here');
1202
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
1203
+ return undefined;
1204
+ },
1205
+ onError: (error, session) => console.error(`[${session.id}]`, error),
1206
+ });
1207
+ ```
1208
+
1209
+ Reading only the headers and returning is the usual cause; read the rest
1210
+ too, even to drop it.
1211
+
1212
+ ### `421 4.3.0 Local error, closing`
1213
+
1214
+ **When**: during a session, then the server hangs up. The reply carries
1215
+ no enhanced code before EHLO.
1216
+
1217
+ **Why**: the server failed while handling a command, outside any hook:
1218
+ a bug in this package. `onError` gets the error.
1219
+
1220
+ **Fix**: report it at
1221
+ [the issue tracker](https://github.com/softistx/bumail/issues), with the
1222
+ error, the session transcript and the `@bumail/smtp` and Bun versions.
1223
+
1224
+ ## Connections
1225
+
1226
+ ### `421 4.7.0 … Too many errors, closing`
1227
+
1228
+ **When**: the `maxErrors`-th failed command of a session (10 by default).
1229
+ The reply replaces the reply to that command, and the server hangs up.
1230
+
1231
+ **Why**: a client that keeps failing is broken or probing. What counts:
1232
+ unknown commands, bad syntax, commands out of order, relay denials,
1233
+ `535`, `553`, `555`, `500 5.5.6`, and `552` at `MAIL FROM`. What does not:
1234
+ a refusal from a hook, `452`, `454 4.7.0 Temporary authentication failure`,
1235
+ and the replies to the end of `DATA`.
1236
+ The count is per connection and is never reset.
1237
+
1238
+ **Fix**, as a client: read the replies before the 421. A common cause is
1239
+ message content pipelined after a refused `DATA`, each line read as a
1240
+ command.
1241
+
1242
+ **Fix**, as the operator: raise `maxErrors` if legitimate clients reach it,
1243
+ such as an MTA sending to many recipients of which most are refused.
1244
+
1245
+ ### `421 4.3.2 … Too many connections, try later`
1246
+
1247
+ **When**: on connecting, in place of the greeting, when `maxConnections`
1248
+ (1000 by default) are already open. The server hangs up.
1249
+
1250
+ **Why**: each connection costs memory and a socket. The 421 tells an MTA
1251
+ to retry later.
1252
+
1253
+ **Fix**, as the operator: raise `maxConnections`, or look for clients that
1254
+ hold connections open without `QUIT`. `server.connections` gives the
1255
+ number open.
1256
+
1257
+ **Fix**, as a client: retry later, and send `QUIT` when done.
1258
+
1259
+ ### `421 4.4.2 … Idle too long, closing`
1260
+
1261
+ **When**: nothing came from the client for `timeout` seconds (300 by
1262
+ default). The server hangs up. Before EHLO the reply has no enhanced code.
1263
+
1264
+ **Why**: RFC 5321 §4.5.3.2.7 lets a server drop a client that has gone
1265
+ quiet, so that a dead client does not hold a connection forever. The time
1266
+ counts from the client's last byte, or from the 220 when nothing came after
1267
+ it, so a slow hook can run into it too — keep `hookTimeout` below
1268
+ `timeout`. Bun's socket timer ticks in steps of about 4 seconds, so the
1269
+ hang-up can come up to that much after `timeout`.
1270
+
1271
+ **Fix**, as a client: send `QUIT` when done; a pooled connection that
1272
+ waits longer must reconnect, or send `NOOP` within the timeout.
1273
+
1274
+ **Fix**, as the operator: raise `timeout`, in seconds, and keep `onData`
1275
+ short: queue the message and answer, then deliver it.
1276
+
1277
+ ```ts
1278
+ import { createSmtpServer } from '@bumail/smtp';
1279
+
1280
+ createSmtpServer({
1281
+ hostname: 'mx.example.com',
1282
+ localDomains: ['example.com'],
1283
+ timeout: 600,
1284
+ onData: async (message) => {
1285
+ await Bun.write(`queue/${message.id}.eml`, await new Response(message.content).bytes());
1286
+ },
1287
+ });
1288
+ ```
1289
+
1290
+ ### `554 … Talked before the greeting`
1291
+
1292
+ **When**: on connecting, in place of the greeting, when the client sent
1293
+ anything before the server's `220` — while `onConnect` ran, or during
1294
+ `greetingDelay`. `…` is the server's `hostname`; there is no enhanced
1295
+ code, since no EHLO came. The server hangs up; nothing the client sent is
1296
+ run. When `onConnect` refuses, its own refusal is sent instead.
1297
+
1298
+ **Why**: RFC 5321 §4.3.1: a client waits for the greeting. Spam engines
1299
+ that blast a whole transaction at once do not, and are cut off here. With
1300
+ `greetingDelay`, the server holds the 220 back for that many seconds, so
1301
+ the window in which an impatient sender gives itself away is longer.
1302
+
1303
+ **Fix**, as a client: wait for the `220` before `EHLO`. Every MTA and
1304
+ client library does; a hand-written client or a test script may not.
1305
+
1306
+ **Fix**, as the operator: if a legitimate sender is refused, lower
1307
+ `greetingDelay`, or leave it at `0`; a test client that writes at once
1308
+ needs it at `0`.