@bumail/smtp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/errors.d.ts +13 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/index.d.ts +9 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +1319 -0
  8. package/dist/index.js.map +29 -0
  9. package/dist/protocol/command.d.ts +20 -0
  10. package/dist/protocol/command.d.ts.map +1 -0
  11. package/dist/protocol/data.d.ts +24 -0
  12. package/dist/protocol/data.d.ts.map +1 -0
  13. package/dist/protocol/path.d.ts +20 -0
  14. package/dist/protocol/path.d.ts.map +1 -0
  15. package/dist/protocol/reply.d.ts +13 -0
  16. package/dist/protocol/reply.d.ts.map +1 -0
  17. package/dist/protocol/sasl.d.ts +16 -0
  18. package/dist/protocol/sasl.d.ts.map +1 -0
  19. package/dist/server/auth.d.ts +6 -0
  20. package/dist/server/auth.d.ts.map +1 -0
  21. package/dist/server/commands.d.ts +4 -0
  22. package/dist/server/commands.d.ts.map +1 -0
  23. package/dist/server/connection.d.ts +52 -0
  24. package/dist/server/connection.d.ts.map +1 -0
  25. package/dist/server/guard.d.ts +18 -0
  26. package/dist/server/guard.d.ts.map +1 -0
  27. package/dist/server/input.d.ts +31 -0
  28. package/dist/server/input.d.ts.map +1 -0
  29. package/dist/server/intake.d.ts +31 -0
  30. package/dist/server/intake.d.ts.map +1 -0
  31. package/dist/server/lines.d.ts +25 -0
  32. package/dist/server/lines.d.ts.map +1 -0
  33. package/dist/server/options.d.ts +151 -0
  34. package/dist/server/options.d.ts.map +1 -0
  35. package/dist/server/received.d.ts +11 -0
  36. package/dist/server/received.d.ts.map +1 -0
  37. package/dist/server/refusals.d.ts +16 -0
  38. package/dist/server/refusals.d.ts.map +1 -0
  39. package/dist/server/server.d.ts +25 -0
  40. package/dist/server/server.d.ts.map +1 -0
  41. package/dist/server/settings.d.ts +16 -0
  42. package/dist/server/settings.d.ts.map +1 -0
  43. package/dist/server/state.d.ts +24 -0
  44. package/dist/server/state.d.ts.map +1 -0
  45. package/dist/server/transaction.d.ts +6 -0
  46. package/dist/server/transaction.d.ts.map +1 -0
  47. package/dist/server/transport.d.ts +42 -0
  48. package/dist/server/transport.d.ts.map +1 -0
  49. package/docs/README.md +10 -0
  50. package/docs/guide.md +976 -0
  51. package/docs/roadmap.md +45 -0
  52. package/docs/troubleshooting.md +1308 -0
  53. package/package.json +55 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # @bumail/smtp
2
+
3
+ An SMTP server for Bun, on `Bun.listen`: RFC 5321 with PIPELINING, SIZE,
4
+ 8BITMIME, SMTPUTF8 and ENHANCEDSTATUSCODES; STARTTLS or implicit TLS; AUTH
5
+ PLAIN and LOGIN, only once encrypted; hooks where your app accepts or
6
+ refuses a connection, a sender, a recipient or a message. It is never an
7
+ open relay. No dependency; `typescript` is an optional peer, for the types.
8
+
9
+ ```sh
10
+ bun add @bumail/smtp
11
+ ```
12
+
13
+ ## An MX on port 25
14
+
15
+ Mail for your domains, from anyone. `onData` gets each message as a
16
+ stream while the client sends it. The client is told `250` only once
17
+ `onData` read `message.content` to its clean end and resolved without a
18
+ refusal; an `onData` that answers before the end, or cancels the stream,
19
+ gets the client `451 4.3.0`, and the message is not taken.
20
+
21
+ ```ts
22
+ import { createSmtpServer } from '@bumail/smtp';
23
+
24
+ const server = createSmtpServer({
25
+ hostname: 'mx.example.com',
26
+ localDomains: ['example.com'],
27
+ tls: {
28
+ key: await Bun.file('/etc/ssl/mx.example.com.key').text(),
29
+ cert: await Bun.file('/etc/ssl/mx.example.com.crt').text(),
30
+ },
31
+ async onData(message) {
32
+ // message.envelope: { from, to, smtputf8, body }
33
+ // message.content: a ReadableStream, this server's Received field on top.
34
+ // Read it to its end: the 250 waits for it, and the read throws if
35
+ // the message must not be delivered.
36
+ const bytes = await new Response(message.content).bytes();
37
+ await Bun.write(`spool/${message.id}.eml`, bytes);
38
+ },
39
+ });
40
+
41
+ await server.listen({ port: 25 });
42
+ ```
43
+
44
+ A recipient outside `localDomains` is refused with `554 5.7.1 Relay access
45
+ denied`. With `tls`, the server offers STARTTLS.
46
+
47
+ ## Submission on 587, with STARTTLS and AUTH
48
+
49
+ Mail from your own users, to anywhere. `mode: 'submission'` refuses `MAIL`
50
+ until the session authenticated; AUTH is offered only after STARTTLS.
51
+
52
+ ```ts
53
+ import { createSmtpServer } from '@bumail/smtp';
54
+
55
+ const users = new Map([['alice', await Bun.password.hash('correct horse')]]);
56
+
57
+ const server = createSmtpServer({
58
+ hostname: 'smtp.example.com',
59
+ mode: 'submission',
60
+ localDomains: ['example.com'],
61
+ tls: {
62
+ key: await Bun.file('/etc/ssl/smtp.example.com.key').text(),
63
+ cert: await Bun.file('/etc/ssl/smtp.example.com.crt').text(),
64
+ },
65
+ async authenticate({ username, password }) {
66
+ const hash = users.get(username);
67
+ return hash !== undefined && (await Bun.password.verify(password, hash));
68
+ },
69
+ async onData(message, session) {
70
+ const bytes = await new Response(message.content).bytes();
71
+ console.log(`${session.user} sent ${message.id} (${bytes.length} bytes) to ${message.envelope.to.join(', ')}`);
72
+ },
73
+ });
74
+
75
+ await server.listen({ port: 587 });
76
+ ```
77
+
78
+ ## Implicit TLS on 465
79
+
80
+ The same submission server, encrypted from the first byte (RFC 8314):
81
+ no STARTTLS, AUTH offered at once.
82
+
83
+ ```ts
84
+ import { createSmtpServer } from '@bumail/smtp';
85
+
86
+ const server = createSmtpServer({
87
+ hostname: 'smtp.example.com',
88
+ mode: 'submission',
89
+ implicitTls: true,
90
+ localDomains: ['example.com'],
91
+ tls: {
92
+ key: await Bun.file('/etc/ssl/smtp.example.com.key').text(),
93
+ cert: await Bun.file('/etc/ssl/smtp.example.com.crt').text(),
94
+ },
95
+ authenticate: ({ username, password }) => username === 'alice' && password === Bun.env['ALICE_PASSWORD'],
96
+ async onData(message) {
97
+ const text = await new Response(message.content).text();
98
+ console.log('received', message.id, text.length);
99
+ },
100
+ });
101
+
102
+ await server.listen({ port: 465 });
103
+ ```
104
+
105
+ ## Hooks for policy
106
+
107
+ A hook returns nothing to accept, or a `reply(code, status, text)` with a
108
+ 4xx or 5xx code to refuse. A hook that throws, answers anything else, or
109
+ does not settle within `hookTimeout` refuses with `451 4.3.0`, a temporary
110
+ failure, so the sender tries again later; `onError` is told why.
111
+
112
+ ```ts
113
+ import { createSmtpServer, reply } from '@bumail/smtp';
114
+
115
+ const blocked = new Set(['203.0.113.7']);
116
+ const mailboxes = new Set(['alice@example.com', 'postmaster@example.com']);
117
+
118
+ const server = createSmtpServer({
119
+ hostname: 'mx.example.com',
120
+ localDomains: ['example.com'],
121
+ onConnect: (session) =>
122
+ blocked.has(session.remoteAddress) ? reply(554, '5.7.1', 'Go away') : undefined,
123
+ onMailFrom: (path) =>
124
+ path.domain === 'spam.example' ? reply(550, '5.7.1', 'Sender refused') : undefined,
125
+ onRcptTo: (path) =>
126
+ mailboxes.has(path.address.toLowerCase()) ? undefined : reply(550, '5.1.1', 'No such user here'),
127
+ async onData(message) {
128
+ await Bun.write(`spool/${message.id}.eml`, await new Response(message.content).bytes());
129
+ },
130
+ onError: (error, session) => console.error(session.id, error),
131
+ });
132
+
133
+ await server.listen({ port: 25 });
134
+ ```
135
+
136
+ The server's own checks run first: `onRcptTo` is never asked about a
137
+ recipient the server already refused, relaying included.
138
+
139
+ ## Limits
140
+
141
+ Every limit has a default; each must be a positive integer —
142
+ `greetingDelay` a number of seconds, `0` or more — or `createSmtpServer`
143
+ throws an `SmtpError` with code `INVALID_OPTION`.
144
+
145
+ ```ts
146
+ import { createSmtpServer } from '@bumail/smtp';
147
+
148
+ const server = createSmtpServer({
149
+ hostname: 'mx.example.com',
150
+ localDomains: ['example.com'],
151
+ maxMessageSize: 10 * 1024 * 1024, // bytes, announced as SIZE; default 25 MiB → 552 5.3.4
152
+ maxRecipients: 50, // per message; default 100 → 452 4.5.3
153
+ maxConnections: 200, // at once; default 1000 → 421 4.3.2
154
+ maxErrors: 5, // failed commands before hanging up; default 10 → 421 4.7.0
155
+ timeout: 120, // idle seconds, from the client's last byte or the 220; default 300 → 421 4.4.2
156
+ hookTimeout: 20, // seconds a hook has to settle; default 60 → 451 4.3.0
157
+ greetingDelay: 5, // seconds before the 220; default 0 → 554 to a client that talks first
158
+ onData: async (message) => {
159
+ await new Response(message.content).bytes();
160
+ },
161
+ });
162
+
163
+ const { port } = await server.listen({ port: 2525, hostname: '127.0.0.1' });
164
+ console.log(`listening on ${port}, ${server.connections} open`);
165
+ // later: server.stop(true) hangs up on every client too
166
+ ```
167
+
168
+ ## Security
169
+
170
+ - **Never an open relay.** A recipient outside `localDomains` is refused
171
+ with `554 5.7.1 Relay access denied` unless the session authenticated —
172
+ before any hook is asked, so no hook can open it. There is no option to
173
+ relay without AUTH. Source routes and quoted local parts cannot smuggle a
174
+ relay past the check.
175
+ - **AUTH only after TLS.** On a clear connection AUTH is not advertised, and
176
+ `AUTH` is refused with `538 5.7.11` before the credentials are read. Three
177
+ failed attempts and the server hangs up.
178
+ - **SMTP smuggling is refused.** Only the exact `<CRLF>.<CRLF>` ends a
179
+ message. A message holding a bare LF or a bare CR is refused whole with
180
+ `550 5.6.11 Bare CR or LF is not allowed in a message`, and its content
181
+ stream ends in an `SmtpError` (`BARE_LINE_BREAK`), so `onData` never
182
+ reads it to a clean end.
183
+ - **Commands pipelined after STARTTLS are dropped.** Whatever the client
184
+ sent in clear behind `STARTTLS` is discarded, never run (CVE-2011-0411),
185
+ and the session starts over: EHLO again, no transaction, no user.
186
+ - **A client that talks before the greeting is refused** with
187
+ `554 <hostname> Talked before the greeting` and the connection closed
188
+ (RFC 5321 §4.3.1); nothing it sent runs. `greetingDelay` holds the 220
189
+ back for that many seconds once `onConnect` accepted, so a sender that
190
+ does not wait gives itself away, as Postfix's postscreen does. A refusal
191
+ from `onConnect` goes out at once, delay or not.
192
+ - **Bounded memory.** The server holds 64 KiB of a client's input and of a
193
+ message at most, and stops reading the client past that; replies to a
194
+ client that reads slowly wait in the server, none is lost.
195
+
196
+ ## Traps
197
+
198
+ - **Read `message.content` to its end before keeping anything.** An
199
+ `onData` that answers before the end gets the client `451 4.3.0`, never
200
+ `250`, and `onError` an `SmtpError` (`MESSAGE_NOT_READ`). The stream ends
201
+ in an `SmtpError` when the message must not be delivered — too big,
202
+ smuggled, the connection gone — and the client is then refused whatever
203
+ `onData` answers. Something written before the stream ended may be a
204
+ message the server refused: delete it when the read throws.
205
+ - **No read may outlive `onData`.** A read left running after `onData`
206
+ answers ends in `MESSAGE_NOT_READ`, and one that starts after
207
+ `hookTimeout` ends in `HOOK_TIMEOUT`: in both cases the client was told
208
+ `451` and will send the message again, so keeping it would deliver it
209
+ twice. Await the read inside `onData`.
210
+ - **An `onData` that read everything but answers after `hookTimeout`
211
+ still gets the client `451`**, though its read ended cleanly. Only
212
+ `message.signal` says so: it aborts whenever the server refuses the
213
+ message on `onData`'s behalf — for example late, a throw, an answer that
214
+ is not a refusal, the stream's own errors, or the connection closed
215
+ before the reply; the guide's table lists every case. A refusal `onData`
216
+ returns leaves it alone, unless the client never hears it: a later
217
+ stream failure (whose 552 or 550 replaces `onData`'s reply) or a closed
218
+ connection still aborts it, with that error. Check
219
+ `if (message.signal.aborted) return;`
220
+ just before keeping a message for good —
221
+ [the guide](https://github.com/softistx/bumail/blob/develop/packages/smtp/docs/guide.md#when-the-refusal-comes-after-the-read)
222
+ has the full example. RFC 5321 §6.1 tolerates a duplicate over a loss, so
223
+ a race left open costs a second copy, never a lost message.
224
+ - `authenticate` needs `tls`, and so does `mode: 'submission'`:
225
+ `createSmtpServer` throws without it.
226
+ - Ports 25, 465 and 587 are below 1024: binding them needs the privilege to,
227
+ or a port forward from a higher one.
228
+ - A PLAIN authorization identity other than the username is refused with
229
+ `535`: a session acts as the user it authenticated as.
230
+ - `Path.local` is kept as written: `<"v@x.example"@example.com>` and
231
+ `<v%x.example@example.com>` are local parts of `example.com`. Never split
232
+ an address on its first `@`.
233
+
234
+ ## API
235
+
236
+ | export | |
237
+ | --- | --- |
238
+ | `createSmtpServer(options)` | the server; throws an `SmtpError` (`INVALID_OPTION`) on a bad option |
239
+ | `SmtpServer` | `listen({ port, hostname? })` (once; again throws `ALREADY_LISTENING`), `stop(closeConnections?)`, `connections` |
240
+ | `SmtpServerOptions` | `hostname`, `mode`, `localDomains`, `tls`, `implicitTls`, `authenticate`, the limits, `hookTimeout`, `greetingDelay`, `onError`, and the hooks |
241
+ | `SmtpError`, `SmtpErrorCode` | `code`: `INVALID_OPTION`, `ALREADY_LISTENING`, and what a content stream or `onError` can get: `MESSAGE_TOO_BIG`, `BARE_LINE_BREAK`, `CONNECTION_LOST`, `HOOK_TIMEOUT`, `INVALID_HOOK_REPLY`, `MESSAGE_NOT_READ` |
242
+ | `SmtpHooks` | `onConnect`, `onMailFrom`, `onRcptTo`, `onData` |
243
+ | `HookResult` | what a hook returns: `undefined` to accept, a `Reply` to refuse |
244
+ | `Session` | `id`, `remoteAddress`, `secure`, `helo`, `esmtp`, `user`, and `data` for your own state |
245
+ | `ReceivedMessage`, `Envelope` | what `onData` receives: `id`, `envelope` (`from`, `to`, `smtputf8`, `body`), `content`, a `ReadableStream<Uint8Array>`, and `signal`, an `AbortSignal` aborted when the server refuses the message on `onData`'s behalf — a refusal `onData` returns leaves it alone, unless a later stream failure replaces that reply or the connection closes before it is sent, which abort it instead |
246
+ | `TlsOptions` | `key` and `cert`, as `Bun.listen` takes them |
247
+ | `Credentials` | what `authenticate` receives: `mechanism`, `username`, `password`, `authorizationId?` |
248
+ | `reply(code, status, text)`, `Reply` | a reply, for a hook to refuse with |
249
+ | `formatReply(reply, enhanced?)` | a reply as sent on the wire, CRLF included |
250
+ | `parsePath(text, allowNull)`, `Path` | `<local@domain>` (RFC 5321 §4.1.2) to `{ address, local, domain }` |
251
+ | `parseCommand(line)`, `Command` | a command line to `{ verb, argument }` |
252
+ | `parsePathCommand(argument, 'FROM' \| 'TO')`, `PathCommand` | the argument of MAIL or RCPT to `{ path, parameters }` |
253
+ | `DataReader`, `DataChunk` | reads DATA: dot-unstuffing, the terminator, bare CR and LF counted |
254
+ | `decodePlain(response)`, `decodeLoginStep(response)` | SASL PLAIN (RFC 4616) and LOGIN responses to `Credentials` or text |
255
+
256
+ ## Documentation
257
+
258
+ These pages ship in the package, under `docs/`.
259
+
260
+ - [Index](https://github.com/softistx/bumail/blob/develop/packages/smtp/docs/README.md): the pages, and when to read each.
261
+ - [Guide](https://github.com/softistx/bumail/blob/develop/packages/smtp/docs/guide.md): the session and every reply, hooks and their order, `Session.data`, what `onData` receives, TLS, the RFCs implemented and what is not.
262
+ - [Troubleshooting](https://github.com/softistx/bumail/blob/develop/packages/smtp/docs/troubleshooting.md): every error, and the replies a client reports.
263
+ - [Roadmap](https://github.com/softistx/bumail/blob/develop/packages/smtp/docs/roadmap.md): what is coming, and what is not planned.
@@ -0,0 +1,13 @@
1
+ /** Why the server refused an option, or why a message stream ended in error. */
2
+ export type SmtpErrorCode = 'INVALID_OPTION' | 'ALREADY_LISTENING' | 'MESSAGE_TOO_BIG' | 'BARE_LINE_BREAK' | 'CONNECTION_LOST' | 'HOOK_TIMEOUT' | 'INVALID_HOOK_REPLY' | 'MESSAGE_NOT_READ';
3
+ /**
4
+ * Thrown by `createSmtpServer` and `listen` for a wrong option or call; the
5
+ * error a message's `content` stream ends with when the message must not be
6
+ * delivered; and what `onError` is given for a hook that broke a rule.
7
+ */
8
+ export declare class SmtpError extends Error {
9
+ readonly name = "SmtpError";
10
+ readonly code: SmtpErrorCode;
11
+ constructor(code: SmtpErrorCode, message: string);
12
+ }
13
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,MAAM,MAAM,aAAa,GACtB,gBAAgB,GAChB,mBAAmB,GACnB,iBAAiB,GACjB,iBAAiB,GACjB,iBAAiB,GACjB,cAAc,GACd,oBAAoB,GACpB,kBAAkB,CAAC;AAEtB;;;;GAIG;AACH,qBAAa,SAAU,SAAQ,KAAK;IACnC,SAAkB,IAAI,eAAe;IACrC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAEjB,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM;CAIhD"}
@@ -0,0 +1,9 @@
1
+ export { SmtpError, type SmtpErrorCode } from './errors';
2
+ export { type Command, type PathCommand, parseCommand, parsePathCommand, } from './protocol/command';
3
+ export { type DataChunk, DataReader } from './protocol/data';
4
+ export { type Path, parsePath } from './protocol/path';
5
+ export { formatReply, type Reply, reply } from './protocol/reply';
6
+ export { type Credentials, decodeLoginStep, decodePlain, } from './protocol/sasl';
7
+ export type { Envelope, HookResult, ReceivedMessage, Session, SmtpHooks, SmtpServerOptions, TlsOptions, } from './server/options';
8
+ export { createSmtpServer, type SmtpServer } from './server/server';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AACzD,OAAO,EACN,KAAK,OAAO,EACZ,KAAK,WAAW,EAChB,YAAY,EACZ,gBAAgB,GAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,KAAK,SAAS,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,KAAK,IAAI,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,KAAK,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAClE,OAAO,EACN,KAAK,WAAW,EAChB,eAAe,EACf,WAAW,GACX,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACX,QAAQ,EACR,UAAU,EACV,eAAe,EACf,OAAO,EACP,SAAS,EACT,iBAAiB,EACjB,UAAU,GACV,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,gBAAgB,EAAE,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC"}