@bumail/smtp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1319 -0
- package/dist/index.js.map +29 -0
- package/dist/protocol/command.d.ts +20 -0
- package/dist/protocol/command.d.ts.map +1 -0
- package/dist/protocol/data.d.ts +24 -0
- package/dist/protocol/data.d.ts.map +1 -0
- package/dist/protocol/path.d.ts +20 -0
- package/dist/protocol/path.d.ts.map +1 -0
- package/dist/protocol/reply.d.ts +13 -0
- package/dist/protocol/reply.d.ts.map +1 -0
- package/dist/protocol/sasl.d.ts +16 -0
- package/dist/protocol/sasl.d.ts.map +1 -0
- package/dist/server/auth.d.ts +6 -0
- package/dist/server/auth.d.ts.map +1 -0
- package/dist/server/commands.d.ts +4 -0
- package/dist/server/commands.d.ts.map +1 -0
- package/dist/server/connection.d.ts +52 -0
- package/dist/server/connection.d.ts.map +1 -0
- package/dist/server/guard.d.ts +18 -0
- package/dist/server/guard.d.ts.map +1 -0
- package/dist/server/input.d.ts +31 -0
- package/dist/server/input.d.ts.map +1 -0
- package/dist/server/intake.d.ts +31 -0
- package/dist/server/intake.d.ts.map +1 -0
- package/dist/server/lines.d.ts +25 -0
- package/dist/server/lines.d.ts.map +1 -0
- package/dist/server/options.d.ts +151 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/received.d.ts +11 -0
- package/dist/server/received.d.ts.map +1 -0
- package/dist/server/refusals.d.ts +16 -0
- package/dist/server/refusals.d.ts.map +1 -0
- package/dist/server/server.d.ts +25 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/settings.d.ts +16 -0
- package/dist/server/settings.d.ts.map +1 -0
- package/dist/server/state.d.ts +24 -0
- package/dist/server/state.d.ts.map +1 -0
- package/dist/server/transaction.d.ts +6 -0
- package/dist/server/transaction.d.ts.map +1 -0
- package/dist/server/transport.d.ts +42 -0
- package/dist/server/transport.d.ts.map +1 -0
- package/docs/README.md +10 -0
- package/docs/guide.md +976 -0
- package/docs/roadmap.md +45 -0
- package/docs/troubleshooting.md +1308 -0
- package/package.json +55 -0
|
@@ -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`.
|