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 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