mailsmtp 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +215 -0
- data/lib/mailsmtp/data_reader.rb +238 -0
- data/lib/mailsmtp/error.rb +58 -0
- data/lib/mailsmtp/errors.rb +85 -0
- data/lib/mailsmtp/server.rb +1199 -0
- data/lib/mailsmtp/session.rb +63 -0
- data/lib/mailsmtp/version.rb +3 -0
- data/lib/mailsmtp.rb +10 -0
- data/mailsmtp.gemspec +37 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dd030bbe1224ef8bce71ca5bc863df0e73285c884c35527202fe2b376cdfb26f
|
|
4
|
+
data.tar.gz: 9daf7326e361f0d214921171d4dcf4b27c2c18e209e59f8317216e09e0089185
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8d74cfca2a76a9994cba453cf444e3ba2095baca76c651918b42f4d2464ae02b61cc0dbedc2e729a07fa201387a86f186d3376e98ed924001273d8955fd5a348
|
|
7
|
+
data.tar.gz: 367b891a056bad4c67fdd3636dddbfe0ab027156d206d3c6e88d163137df1bb46fbb5b1e59afd0ad172ffec5d4f7afa0735122c382b1a66cb32ec52d853e692d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Fail `MailSmtp::Error` subclasses that omit `status` at definition time, not on `#response`
|
|
6
|
+
- Record `MAIL FROM SIZE=` on `Envelope#declared_size` and include declared/limit sizes in oversize `552`s
|
|
7
|
+
- Default `forbid_bare_newline` to `true`
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
- Added `auth_mechanisms` so EHLO `AUTH` advertisement is subclass-controlled (default PLAIN LOGIN)
|
|
12
|
+
- Added `DataReader` for streaming DATA into `receive(session, io)`
|
|
13
|
+
- Added SIZE enforcement on `max_message_size`, dropping the connection after `552`
|
|
14
|
+
- Added `max_auth_failures` (default 3) and `max_exceptions` (default 20), each closing the session with `421`
|
|
15
|
+
- Added `max_session_duration` (default 30 minutes) and an idle timer
|
|
16
|
+
- Added `handshake_timeout` (default 30 seconds) for STARTTLS
|
|
17
|
+
- Added RFC 2034 `ENHANCEDSTATUSCODES` support
|
|
18
|
+
- Added `forbid_bare_newline` (default on) to reject non-CRLF-terminated DATA lines
|
|
19
|
+
- Added PROXY protocol v1 support
|
|
20
|
+
- Added TLS hot-swap via `#tls=`
|
|
21
|
+
- Added `session.tls_version` and `session.tls_cipher` after STARTTLS
|
|
22
|
+
- Declared `base64` and `logger` as runtime dependencies
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Lev
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# mailsmtp
|
|
2
|
+
|
|
3
|
+
π¬ A boring SMTP server for Ruby
|
|
4
|
+
|
|
5
|
+
**mailsmtp implements the SMTP protocol so you can plug in your own logic.** Receive email in your own app β no Postfix, no ESP required. As the conversation progresses, you decide what AUTH, MAIL FROM, RCPT TO, and DATA mean.
|
|
6
|
+
|
|
7
|
+
mailsmtp handles:
|
|
8
|
+
|
|
9
|
+
- the protocol - AUTH, MAIL FROM, RCPT TO, DATA, STARTTLS, PIPELINING
|
|
10
|
+
- streaming - `receive` gets a live `io` into the DATA stream, no buffering the whole message
|
|
11
|
+
- size limits - SIZE is advertised and enforced as DATA arrives
|
|
12
|
+
- smuggling protection - bare LF lines are rejected by default
|
|
13
|
+
- backpressure - concurrent DATA transfers are capped, with a queue and timeout
|
|
14
|
+
- hot reloading - swap the STARTTLS certificate without restarting
|
|
15
|
+
|
|
16
|
+
Plus:
|
|
17
|
+
|
|
18
|
+
- subclass-and-override hooks for every verb
|
|
19
|
+
- raise a typed error to choose the SMTP reply
|
|
20
|
+
- multi-listen across any number of hostΓport pairs
|
|
21
|
+
- injectable logger and live connection/processing counters
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Contents
|
|
26
|
+
|
|
27
|
+
- [Installation](#installation)
|
|
28
|
+
- [Getting Started](#getting-started)
|
|
29
|
+
- [Design](#design)
|
|
30
|
+
- [Role β server only](#role--server-only)
|
|
31
|
+
- [Hooks β subclass verbs](#hooks--subclass-verbs)
|
|
32
|
+
- [DATA β live reader into receive](#data--live-reader-into-receive)
|
|
33
|
+
- [Reference](#reference)
|
|
34
|
+
- [Hooks](#hooks)
|
|
35
|
+
- [Options](#options)
|
|
36
|
+
- [History](#history)
|
|
37
|
+
- [Contributing](#contributing)
|
|
38
|
+
- [License](#license)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
Add this line to your application's Gemfile:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
gem "mailsmtp"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Ruby >= 3.3.4. Runtime dependencies: `base64`, `logger`.
|
|
51
|
+
|
|
52
|
+
## Getting Started
|
|
53
|
+
|
|
54
|
+
Subclass `MailSmtp::Server` and override the hooks you care about.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
require "mailsmtp"
|
|
58
|
+
|
|
59
|
+
class MyServer < MailSmtp::Server
|
|
60
|
+
def connected(session)
|
|
61
|
+
session.local_response = "mail.example.com ESMTP"
|
|
62
|
+
session.helo_response = "mail.example.com"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def authenticate(session, username:, password:, authorization_id: "")
|
|
66
|
+
raise MailSmtp::AuthenticationFailed unless username == "user" && password == "secret"
|
|
67
|
+
username # becomes session.authorization_id when non-empty
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def mail_from(session, address)
|
|
71
|
+
address[/<([^>]*)>/, 1] || address # returned value is stored on the envelope
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rcpt_to(session, address)
|
|
75
|
+
address[/<([^>]*)>/, 1] || address
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def receive(session, io)
|
|
79
|
+
# Prefer streaming β io.read materializes the whole message in memory.
|
|
80
|
+
IO.copy_stream(io, storage_destination)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
And start it:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
server = MyServer.new(
|
|
89
|
+
host: "127.0.0.1",
|
|
90
|
+
port: 2525,
|
|
91
|
+
tls: nil,
|
|
92
|
+
auth: :disabled,
|
|
93
|
+
starttls: :optional,
|
|
94
|
+
max_connections: 100,
|
|
95
|
+
max_processings: 4,
|
|
96
|
+
max_message_size: 10_485_760,
|
|
97
|
+
max_recipients: 100,
|
|
98
|
+
read_timeout: 300,
|
|
99
|
+
write_timeout: 30,
|
|
100
|
+
handshake_timeout: 30
|
|
101
|
+
)
|
|
102
|
+
server.start
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Hot-swap the STARTTLS certificate without restarting:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
server.tls = new_tls_context
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Multi-listen: `hosts:` / `ports:` expand to every hostΓport pair.
|
|
112
|
+
|
|
113
|
+
## Design
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
### Role β server only
|
|
118
|
+
|
|
119
|
+
mailsmtp is an SMTP **server**. Outbound submission (`Net::SMTP`, your MTA, Action Mailer) stays outside the gem.
|
|
120
|
+
|
|
121
|
+
### Hooks β subclass verbs
|
|
122
|
+
|
|
123
|
+
Subclass `MailSmtp::Server` and override methods: `authenticate`, `auth_mechanisms`, `mail_from`, `rcpt_to`, `receive`, plus `build_session` / `process_line` when you need a custom session or command gate.
|
|
124
|
+
|
|
125
|
+
One server object owns policy; `Session` is connection state (envelope, auth), not a second interface you implement. Raise a `MailSmtp::Error` subclass to choose the SMTP reply.
|
|
126
|
+
|
|
127
|
+
### DATA β live reader into receive
|
|
128
|
+
|
|
129
|
+
After `354`, the engine builds a `DataReader` over the socket (dot-unstuffing, SIZE, line limits) and calls `receive(session, io)`. Prefer `IO.copy_stream` (or chunked reads); the engine **drains** through `.` then sends **one** reply β except when SIZE/`MessageTooLarge` (or the drain byte ceiling) forces a close after the error reply (no unbounded drain).
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
def receive(session, io)
|
|
133
|
+
IO.copy_stream(io, storage_destination)
|
|
134
|
+
# or: while (chunk = io.read(8192)); ...; end β read(len) returns nil at EOF
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
SIZE / line-length failures raise a `MailSmtp::Error` from `read` / `readpartial` (not a short successful body). Mid-DATA disconnect raises `ServiceUnavailable`. If `receive` ignores the io, the reply still comes from the reader's failure after drain.
|
|
139
|
+
|
|
140
|
+
## Reference
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
### Hooks
|
|
145
|
+
|
|
146
|
+
Raising a `MailSmtp::Error` subclass sends that SMTP reply to the client. Other exceptions during `receive` become a 451 (`LocalError`). Unexpected errors outside DATA still need cleanup (prefer raising a defined error).
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
| Hook | Signature | Return / raise |
|
|
150
|
+
| ------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
|
|
151
|
+
| `connected` | `(session)` | β |
|
|
152
|
+
| `disconnected` | `(session)` | β |
|
|
153
|
+
| `helo` | `(session, name)` | after STARTTLS, `session.tls_version` / `session.tls_cipher` are set |
|
|
154
|
+
| `proxy` | `(session, proxy_data)` | hash replaces PROXY data |
|
|
155
|
+
| `authenticate` | `(session, username:, password:, authorization_id:)` | value becomes `authorization_id` (else username); raise `AuthenticationFailed` |
|
|
156
|
+
| `auth_mechanisms` | `(session)` | |
|
|
157
|
+
| `mail_from` | `(session, address)` | returned string is stored as envelope from |
|
|
158
|
+
| `rcpt_to` | `(session, address)` | returned string is appended to envelope to |
|
|
159
|
+
| `receiving_started` | `(session)` | once, before `receive` |
|
|
160
|
+
| `headers_received` | `(session)` | when the header/body blank line is seen while reading |
|
|
161
|
+
| `receive` | `(session, io)` | read the `DataReader` (or discard); raise to reject |
|
|
162
|
+
| `received_header` | `(session)` | string prefixed onto the reader when `add_received: true` |
|
|
163
|
+
| `build_session` | `()` | a `Session` (or subclass) |
|
|
164
|
+
| `process_line` | `(session, line)` | override for policy; call `super` for dispatch |
|
|
165
|
+
| `unknown_command` | `(session, line)` | default raises `CommandNotRecognized` |
|
|
166
|
+
| `log` | `(session, severity, msg, err:)` | β |
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
### Options
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
| Option | Default | Notes |
|
|
175
|
+
| ---------------------- | ------------------- | ------------------------------------------------------------------------------------------- |
|
|
176
|
+
| `auth` | required arg | `:disabled` or `:required` |
|
|
177
|
+
| `eight_bit_mime` | `false` | RFC 6152 |
|
|
178
|
+
| `smtputf8` | `false` | RFC 6531; when off, non-ASCII envelope addresses are refused |
|
|
179
|
+
| `pipelining` | `false` | RFC 2920; STARTTLS always drops plaintext buffers |
|
|
180
|
+
| `proxy_hosts` | `[]` | PROXY v1 from these IPs/CIDRs |
|
|
181
|
+
| `add_received` | `false` | Prefix `received_header` onto the DATA reader |
|
|
182
|
+
| `forbid_bare_newline` | `true` | Reject DATA lines that end with bare LF (SMTP smuggling) |
|
|
183
|
+
| `max_message_size` | `10485760` (10 MiB) | Advertises SIZE and enforces as DATA arrives; `nil` disables |
|
|
184
|
+
| `max_recipients` | `100` | Minimum 100 (RFC 5321); enforced at RCPT TO |
|
|
185
|
+
| `max_processings` | required arg | Concurrent DATA transfers; idle sessions do not consume a slot |
|
|
186
|
+
| `max_processings_wait` | `30` | Seconds to wait for a DATA processing slot before 421 |
|
|
187
|
+
| `max_auth_failures` | `3` | Failed AUTH attempts before 421; `nil` disables |
|
|
188
|
+
| `max_exceptions` | `20` | Protocol errors before 421; `nil` disables |
|
|
189
|
+
| `read_timeout` | `300` | Idle read timeout (seconds); RFC 5321 Β§4.5.3.2.7; `nil` disables |
|
|
190
|
+
| `write_timeout` | `30` | Reply write timeout (seconds) |
|
|
191
|
+
| `handshake_timeout` | `30` | STARTTLS handshake timeout (seconds); independent of `read_timeout`; `nil` falls back to 30 |
|
|
192
|
+
| `max_session_duration` | `1800` | Absolute session ceiling (seconds); `nil` to disable |
|
|
193
|
+
| `logger` | stdout logger | Injectable `Logger` |
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
`starttls: :required` requires a non-nil `tls` object. Per-IP connection caps are deferred for peers in `proxy_hosts` until PROXY rewrites the client address. Forced `#stop` aborts lingering sockets with RST (`SO_LINGER 0`). Inspect live load with `connections_count` / `processings_count` (and `connections?`).
|
|
197
|
+
|
|
198
|
+
## History
|
|
199
|
+
|
|
200
|
+
View the [changelog](CHANGELOG).
|
|
201
|
+
|
|
202
|
+
## Contributing
|
|
203
|
+
|
|
204
|
+
Everyone is encouraged to help improve this project:
|
|
205
|
+
|
|
206
|
+
- [Report bugs](https://github.com/mailpiece/mailsmtp/issues)
|
|
207
|
+
- Fix bugs and submit pull requests
|
|
208
|
+
- Write, clarify, or fix documentation
|
|
209
|
+
- Suggest or add new features
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailSmtp
|
|
4
|
+
# IO-like view of an SMTP DATA body. Yields dot-unstuffed bytes as the
|
|
5
|
+
# underlying connection produces lines; ends at the terminating "." line.
|
|
6
|
+
# SIZE and per-line limits set +#failure+ and raise on +#read+ / +#readpartial+
|
|
7
|
+
# (never return a truncated body as success). +#drain+ consumes through "."
|
|
8
|
+
# without raising so the session can reply once, but still records SIZE/line
|
|
9
|
+
# failures (and an absolute byte ceiling when SIZE is nil) so a no-op
|
|
10
|
+
# +receive+ cannot turn an oversize body into 250. Drain always stops once
|
|
11
|
+
# that ceiling is hit β unbounded drain would let a fast sender burn the link.
|
|
12
|
+
#
|
|
13
|
+
# +readpartial+ fills up to +maxlen+ (or the terminating "."), so a mid-DATA
|
|
14
|
+
# disconnect raises +ServiceUnavailable+ instead of returning a short chunk that
|
|
15
|
+
# apps treat as end-of-message. +EOFError+ is raised only after the ".".
|
|
16
|
+
class DataReader
|
|
17
|
+
# Absolute drain ceiling when the server has no SIZE limit configured.
|
|
18
|
+
# Keep in sync with Server::DEFAULT_MAX_MESSAGE_SIZE (this file loads first).
|
|
19
|
+
DRAIN_BYTE_CEILING = 10_485_760
|
|
20
|
+
|
|
21
|
+
attr_reader :failure
|
|
22
|
+
|
|
23
|
+
def initialize(max_message_size: nil, max_line_length: Server::MAX_TEXT_LINE_LENGTH,
|
|
24
|
+
on_headers: nil, &read_line)
|
|
25
|
+
raise ArgumentError, "read_line block required" unless read_line
|
|
26
|
+
|
|
27
|
+
@read_line = read_line
|
|
28
|
+
@max_message_size = max_message_size
|
|
29
|
+
@max_line_length = max_line_length
|
|
30
|
+
@on_headers = on_headers
|
|
31
|
+
@buffer = +"".b
|
|
32
|
+
@bytes_seen = 0
|
|
33
|
+
@eof = false
|
|
34
|
+
@failure = nil
|
|
35
|
+
@draining = false
|
|
36
|
+
@headers_seen = false
|
|
37
|
+
@drain_ceiling_hit = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def read(length = nil, outbuf = nil)
|
|
41
|
+
raise @failure if @failure && !@draining
|
|
42
|
+
|
|
43
|
+
# IO#read(0) returns an empty string without consuming input.
|
|
44
|
+
if length == 0
|
|
45
|
+
chunk = +""
|
|
46
|
+
return outbuf ? outbuf.replace(chunk) : chunk
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if length
|
|
50
|
+
fill_until { @buffer.bytesize >= length || @eof || @failure }
|
|
51
|
+
else
|
|
52
|
+
fill_until { @eof || @failure }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
raise @failure if @failure && !@draining
|
|
56
|
+
|
|
57
|
+
if @buffer.empty?
|
|
58
|
+
# IO#read(length) returns nil at EOF; read() with no length returns "".
|
|
59
|
+
chunk = length ? nil : +""
|
|
60
|
+
elsif length
|
|
61
|
+
chunk = @buffer.slice!(0, length)
|
|
62
|
+
else
|
|
63
|
+
chunk = @buffer
|
|
64
|
+
@buffer = +""
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if outbuf
|
|
68
|
+
outbuf.replace(chunk || +"")
|
|
69
|
+
chunk
|
|
70
|
+
else
|
|
71
|
+
chunk
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def readpartial(maxlen, outbuf = nil)
|
|
76
|
+
raise ArgumentError, "maxlen must be positive" unless maxlen.positive?
|
|
77
|
+
raise @failure if @failure && !@draining
|
|
78
|
+
|
|
79
|
+
# Fill up to maxlen (not "any bytes") so a peer drop is discovered on this
|
|
80
|
+
# call β returning the first line alone lets short-read loops treat a
|
|
81
|
+
# truncated body as complete while the engine later 451s / redelivers.
|
|
82
|
+
fill_until { @buffer.bytesize >= maxlen || @eof || @failure }
|
|
83
|
+
|
|
84
|
+
raise @failure if @failure && !@draining
|
|
85
|
+
if @buffer.empty?
|
|
86
|
+
# EOFError only after the terminating "."; a drop mid-DATA must not look
|
|
87
|
+
# like a clean end-of-message (apps that rescue EOFError would accept a
|
|
88
|
+
# truncated body and the engine would map that to LocalError / 451).
|
|
89
|
+
raise EOFError, "end of data" if @eof
|
|
90
|
+
raise ServiceUnavailable, "Connection lost during DATA"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
chunk = @buffer.slice!(0, maxlen)
|
|
94
|
+
outbuf ? outbuf.replace(chunk) : chunk
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def eof?
|
|
98
|
+
@eof && @buffer.empty? && (@draining || @failure.nil?)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def drain
|
|
102
|
+
@draining = true
|
|
103
|
+
@buffer = +""
|
|
104
|
+
# Stop on SIZE, or on the absolute drain ceiling when SIZE is off / after
|
|
105
|
+
# LineTooLong β never read forever waiting for ".".
|
|
106
|
+
fill_until { @drain_ceiling_hit || @failure.is_a?(MessageTooLarge) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def drain_ceiling_hit? = @drain_ceiling_hit
|
|
110
|
+
|
|
111
|
+
def binmode = self
|
|
112
|
+
def close = nil
|
|
113
|
+
def closed? = false
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
def drain_ceiling
|
|
117
|
+
@max_message_size || DRAIN_BYTE_CEILING
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fill_until
|
|
121
|
+
loop do
|
|
122
|
+
return if @eof
|
|
123
|
+
return if !@draining && @failure
|
|
124
|
+
return if @draining && @drain_ceiling_hit
|
|
125
|
+
return if yield
|
|
126
|
+
|
|
127
|
+
pull_line
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pull_line
|
|
132
|
+
line = @read_line.call
|
|
133
|
+
if line.nil?
|
|
134
|
+
@eof = true
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Count every network line toward SIZE, including ones we discard after failure.
|
|
139
|
+
@bytes_seen += line.bytesize
|
|
140
|
+
line = line.b
|
|
141
|
+
|
|
142
|
+
# Record protocol failures before the drain-ceiling stop. Ceiling abort
|
|
143
|
+
# alone must not leave +failure+ nil β default +receive+ never reads, so
|
|
144
|
+
# SIZE/line limits are only observed here during +drain+, and a nil
|
|
145
|
+
# failure would become a spurious 250.
|
|
146
|
+
if line.bytesize > @max_line_length
|
|
147
|
+
@failure ||= LineTooLong.new
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if @max_message_size && @bytes_seen > @max_message_size
|
|
151
|
+
@failure ||= MessageTooLarge.new
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if @draining && @bytes_seen > drain_ceiling
|
|
155
|
+
@drain_ceiling_hit = true
|
|
156
|
+
# Absolute ceiling (no SIZE configured): still refuse rather than 250.
|
|
157
|
+
@failure ||= MessageTooLarge.new
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if !@draining && !@headers_seen && line == "\r\n".b
|
|
162
|
+
@headers_seen = true
|
|
163
|
+
@on_headers&.call
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
return if @draining || @failure
|
|
167
|
+
|
|
168
|
+
@buffer << line
|
|
169
|
+
rescue ServiceUnavailable
|
|
170
|
+
raise
|
|
171
|
+
rescue Error => e
|
|
172
|
+
@failure ||= e
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Prepends a fixed string once, then delegates β used for +add_received+.
|
|
177
|
+
# Duck-types the same IO surface as +DataReader+.
|
|
178
|
+
class PrefixedReader
|
|
179
|
+
def initialize(prefix, inner)
|
|
180
|
+
@prefix = prefix.to_s.b
|
|
181
|
+
@inner = inner
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def read(length = nil, outbuf = nil)
|
|
185
|
+
# Match DataReader / IO#read(0).
|
|
186
|
+
if length == 0
|
|
187
|
+
chunk = +""
|
|
188
|
+
return outbuf ? outbuf.replace(chunk) : chunk
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if @prefix
|
|
192
|
+
if length.nil?
|
|
193
|
+
chunk = @prefix
|
|
194
|
+
@prefix = nil
|
|
195
|
+
rest = @inner.read
|
|
196
|
+
chunk = rest ? "#{chunk}#{rest}" : chunk
|
|
197
|
+
elsif length >= @prefix.bytesize
|
|
198
|
+
chunk = @prefix
|
|
199
|
+
@prefix = nil
|
|
200
|
+
rest_len = length - chunk.bytesize
|
|
201
|
+
rest = rest_len.positive? ? @inner.read(rest_len) : +""
|
|
202
|
+
chunk = rest ? "#{chunk}#{rest}" : chunk
|
|
203
|
+
else
|
|
204
|
+
chunk = @prefix.slice!(0, length)
|
|
205
|
+
end
|
|
206
|
+
outbuf ? outbuf.replace(chunk) : chunk
|
|
207
|
+
else
|
|
208
|
+
@inner.read(length, outbuf)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def readpartial(maxlen, outbuf = nil)
|
|
213
|
+
if @prefix && !@prefix.empty?
|
|
214
|
+
chunk = @prefix.slice!(0, maxlen)
|
|
215
|
+
@prefix = nil if @prefix.empty?
|
|
216
|
+
outbuf ? outbuf.replace(chunk) : chunk
|
|
217
|
+
else
|
|
218
|
+
@inner.readpartial(maxlen, outbuf)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def drain
|
|
223
|
+
@prefix = nil
|
|
224
|
+
@inner.drain
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def failure = @inner.failure
|
|
228
|
+
def drain_ceiling_hit? = @inner.drain_ceiling_hit?
|
|
229
|
+
|
|
230
|
+
def eof?
|
|
231
|
+
(@prefix.nil? || @prefix.empty?) && @inner.eof?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def binmode = self
|
|
235
|
+
def close = @inner.close
|
|
236
|
+
def closed? = @inner.closed?
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module MailSmtp
|
|
2
|
+
class Error < RuntimeError
|
|
3
|
+
class << self
|
|
4
|
+
# +enhanced+ is an RFC 2034 / 3463 status like "5.3.4".
|
|
5
|
+
def status(code, text, enhanced:)
|
|
6
|
+
@code = code
|
|
7
|
+
@text = text
|
|
8
|
+
@enhanced = enhanced
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def inherited(subclass)
|
|
12
|
+
# +class Foo < Error+ emits TracePoint :end; Class.new(Error) goes through
|
|
13
|
+
# Class#initialize and only finishes on that method's :c_return (after any block).
|
|
14
|
+
if caller_locations(1, 1).first&.label == "Class#initialize"
|
|
15
|
+
TracePoint.trace(:c_return) do |tp|
|
|
16
|
+
next unless tp.method_id == :initialize &&
|
|
17
|
+
tp.defined_class == Class &&
|
|
18
|
+
tp.return_value.equal?(subclass)
|
|
19
|
+
tp.disable
|
|
20
|
+
subclass.assert_status!
|
|
21
|
+
end
|
|
22
|
+
else
|
|
23
|
+
TracePoint.trace(:end) do |tp|
|
|
24
|
+
next unless tp.self.equal?(subclass)
|
|
25
|
+
tp.disable
|
|
26
|
+
subclass.assert_status!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def assert_status!
|
|
32
|
+
if code.nil? || text.nil? || enhanced.nil?
|
|
33
|
+
raise "MailSmtp::Error subclass #{self} must declare status"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def code
|
|
38
|
+
return @code if instance_variable_defined?(:@code)
|
|
39
|
+
superclass.code if superclass < Error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def text
|
|
43
|
+
return @text if instance_variable_defined?(:@text)
|
|
44
|
+
superclass.text if superclass < Error
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def enhanced
|
|
48
|
+
return @enhanced if instance_variable_defined?(:@enhanced)
|
|
49
|
+
superclass.enhanced if superclass < Error
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def response
|
|
54
|
+
self.class.assert_status!
|
|
55
|
+
"#{self.class.code} #{self.class.enhanced} #{self.class.text}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module MailSmtp
|
|
2
|
+
# Protocol-level SMTP replies the engine and its subclasses raise. Product
|
|
3
|
+
# policy (relay denied, rate limited, β¦) stays in the application. Every
|
|
4
|
+
# reply carries an RFC 2034 enhanced status code.
|
|
5
|
+
|
|
6
|
+
# 421 β connection is being dropped: too busy, timed out, or abusing the protocol
|
|
7
|
+
class ServiceUnavailable < Error
|
|
8
|
+
status 421, "Service too busy or not available, closing transmission channel", enhanced: "4.3.2"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# 451 β the app hit an unexpected error handling an otherwise-valid message
|
|
12
|
+
class LocalError < Error
|
|
13
|
+
status 451, "Requested action aborted: local error in processing", enhanced: "4.3.0"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 452 β envelope has more recipients than we allow in one transaction
|
|
17
|
+
class TooManyRecipients < Error
|
|
18
|
+
status 452, "Requested action not taken: insufficient system storage", enhanced: "4.5.3"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 500 β command verb not recognized
|
|
22
|
+
class CommandNotRecognized < Error
|
|
23
|
+
status 500, "Syntax error, command unrecognised", enhanced: "5.5.1"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 500 β a DATA body line exceeded the RFC 5321 text-line limit
|
|
27
|
+
class LineTooLong < Error
|
|
28
|
+
status 500, "Line too long", enhanced: "5.5.2"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 502 β command recognized but not implemented (VRFY's cousins EXPN/HELP)
|
|
32
|
+
class CommandNotImplemented < Error
|
|
33
|
+
status 502, "Command not implemented", enhanced: "5.5.1"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 500 β a second command arrived pipelined ahead of its reply, which we don't support
|
|
37
|
+
class PipeliningNotSupported < Error
|
|
38
|
+
status 500, "Bad input, PIPELINING is not allowed", enhanced: "5.5.2"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 501 β a command parameter we don't support (e.g. SMTPUTF8)
|
|
42
|
+
class InvalidParameters < Error
|
|
43
|
+
status 501, "Syntax error in parameters or arguments", enhanced: "5.5.4"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 503 β command arrived out of the expected HELO/AUTH/MAIL/RCPT/DATA sequence
|
|
47
|
+
class BadSequence < Error
|
|
48
|
+
status 503, "Bad sequence of commands", enhanced: "5.5.1"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 504 β an AUTH mechanism we don't offer (RFC 4954 Β§4); only PLAIN and LOGIN
|
|
52
|
+
class AuthenticationTypeNotSupported < Error
|
|
53
|
+
status 504, "Unrecognized authentication type", enhanced: "5.5.4"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 530 β TLS is required and the client hasn't issued STARTTLS yet
|
|
57
|
+
class EncryptionRequired < Error
|
|
58
|
+
status 530, "Encryption required, must issue STARTTLS command first", enhanced: "5.7.0"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 530 β AUTH is required and the client hasn't authenticated yet
|
|
62
|
+
class AuthenticationRequired < Error
|
|
63
|
+
status 530, "Authentication required", enhanced: "5.7.0"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 535 β AUTH credentials were rejected
|
|
67
|
+
class AuthenticationFailed < Error
|
|
68
|
+
status 535, "Authentication credentials invalid", enhanced: "5.7.8"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 550 β DATA contained a bare LF (SMTP smuggling / non-CRLF line endings)
|
|
72
|
+
class BareNewline < Error
|
|
73
|
+
status 550, "Bare <LF> disallowed; lines must end with <CRLF>", enhanced: "5.5.2"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 552 β declared or actual message size exceeds the configured limit
|
|
77
|
+
class MessageTooLarge < Error
|
|
78
|
+
status 552, "Requested mail action aborted: exceeded storage allocation", enhanced: "5.3.4"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 555 β an ESMTP parameter we don't implement (RFC 1869 Β§6.1)
|
|
82
|
+
class ParametersNotRecognized < Error
|
|
83
|
+
status 555, "MAIL FROM/RCPT TO parameters not recognized or not implemented", enhanced: "5.5.4"
|
|
84
|
+
end
|
|
85
|
+
end
|