Class: MailSmtp::Server
- Inherits:
-
Object
- Object
- MailSmtp::Server
- Defined in:
- lib/mailsmtp/server.rb
Overview
A thread-per-connection SMTP server (no client). Handles the RFC 5321
command sequence (HELO/EHLO, AUTH PLAIN/LOGIN, MAIL FROM, RCPT TO, DATA,
STARTTLS, RSET, NOOP, QUIT, VRFY), leaving domain policy to subclass hooks.
SIZE is advertised and enforced when max_message_size is set; 8BITMIME,
SMTPUTF8, PIPELINING, PROXY, and multi-listen are optional.
STARTTLS always drops any plaintext still buffered after the handshake
(CVE-2011-0411). Implicit TLS (port 465) is not supported yet.
Policy is subclass verbs (+authenticate+, mail_from, rcpt_to, receive).
During DATA the engine passes a live DataReader into receive(session, io)
(dot-unstuffed socket bytes); the app reads or discards, then the engine
drains to "." and replies once.
tls is any object that responds to #start(io) and returns an SSL socket,
or nil to disable STARTTLS. Reassign tls to hot-swap certificates.
Constant Summary collapse
- AUTH_MODES =
%i[ disabled required ].freeze
- STARTTLS_MODES =
%i[ optional required ].freeze
- AUTH_LOGIN_USERNAME_PROMPT =
RFC 4954 §4: AUTH LOGIN prompts with base64 "Username:" then "Password:".
"334 #{Base64.strict_encode64("Username:")}".freeze
- AUTH_LOGIN_PASSWORD_PROMPT =
"334 #{Base64.strict_encode64("Password:")}".freeze
- IO_BUFFER_CHUNK_SIZE =
4 * 1024
- IO_WAIT_TIMEOUT =
1- DEFAULT_IO_BUFFER_MAX_SIZE =
1 * 1024 * 1024
- DEFAULT_READ_TIMEOUT =
RFC 5321 §4.5.3.2.7: servers should wait at least 5 minutes for the next client command. (The earlier §4.5.3.2.1–.6 times are client-side.)
300- DEFAULT_WRITE_TIMEOUT =
30- DEFAULT_HANDSHAKE_TIMEOUT =
30- DEFAULT_MAX_PROCESSINGS_WAIT =
30- DEFAULT_MAX_RECIPIENTS =
100- DEFAULT_MAX_MESSAGE_SIZE =
10 MiB
10_485_760- DEFAULT_MAX_SESSION_DURATION =
Absolute ceiling so idle resets on each line cannot stretch a session forever.
1800- DEFAULT_MAX_AUTH_FAILURES =
3- DEFAULT_MAX_EXCEPTIONS =
20- ABORT_REPLY_WRITE_TIMEOUT =
Short write budget for a 421 abort after a normal write already timed out, so we do not stack another full
write_timeoutbefore dropping the socket. 2- MAX_COMMAND_LINE_LENGTH =
RFC 5321 §4.5.3.1.4 / §4.5.3.1.6 (octets, including the CRLF).
512- MAX_TEXT_LINE_LENGTH =
1000- MAX_AUTH_LINE_LENGTH =
RFC 4954 §4: AUTH base64 responses (commands and continuations) may be up to 12288 octets — not the 512-octet SMTP command-line ceiling.
12_288- MAX_FAILED_ACCEPTS =
10- FAILED_ACCEPT_BACKOFF =
0.5
Instance Attribute Summary collapse
-
#max_connections ⇒ Object
readonly
Returns the value of attribute max_connections.
-
#max_connections_per_ip ⇒ Object
readonly
Returns the value of attribute max_connections_per_ip.
-
#max_processings ⇒ Object
readonly
Returns the value of attribute max_processings.
-
#tls ⇒ Object
Reassign between STARTTLS negotiations to hot-swap certificates (e.g. ACME).
Instance Method Summary collapse
- #addresses ⇒ Object
-
#auth_mechanisms(_session) ⇒ Object
SASL mechanism names advertised on EHLO (
250-AUTH …) and accepted byAUTH. - #authenticate(session, username:, password:, authorization_id: "") ⇒ Object
-
#build_session ⇒ Object
Override to supply a Session subclass.
- #connected(session) ⇒ Object
- #connections? ⇒ Boolean
- #connections_count ⇒ Object
- #disconnected(session) ⇒ Object
- #headers_received(session) ⇒ Object
- #helo(session, name) ⇒ Object
-
#initialize(tls:, max_connections:, max_processings:, auth:, starttls:, host: nil, port: nil, hosts: nil, ports: nil, max_connections_per_ip: nil, eight_bit_mime: false, smtputf8: false, pipelining: false, proxy_hosts: [], add_received: false, forbid_bare_newline: true, max_message_size: DEFAULT_MAX_MESSAGE_SIZE, max_recipients: DEFAULT_MAX_RECIPIENTS, max_processings_wait: DEFAULT_MAX_PROCESSINGS_WAIT, read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT, handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT, max_session_duration: DEFAULT_MAX_SESSION_DURATION, max_auth_failures: DEFAULT_MAX_AUTH_FAILURES, max_exceptions: DEFAULT_MAX_EXCEPTIONS, io_buffer_max_size: DEFAULT_IO_BUFFER_MAX_SIZE, logger: nil, logger_severity: Logger::INFO) ⇒ Server
constructor
+host+/+port+ bind a single address.
- #log(session, severity, msg, err: nil) ⇒ Object
- #mail_from(session, address) ⇒ Object
-
#process_line(session, line) ⇒ Object
Override for per-command policy (rate limits, session expiry, …).
- #processings_count ⇒ Object
-
#proxy(session, proxy_data) ⇒ Object
Optional: inspect or replace PROXY data after a valid PROXY line.
- #rcpt_to(session, address) ⇒ Object
-
#receive(session, io) ⇒ Object
iois aDataReader(orPrefixedReader) of dot-unstuffed message bytes. -
#received_header(session) ⇒ Object
Override to customize the opt-in Received header (+add_received: true+).
- #receiving_started(session) ⇒ Object
- #shutdown? ⇒ Boolean
- #start ⇒ Object
- #stop(wait_seconds_before_close: 2) ⇒ Object
- #stopped? ⇒ Boolean
- #unknown_command(_session, _line) ⇒ Object
Constructor Details
#initialize(tls:, max_connections:, max_processings:, auth:, starttls:, host: nil, port: nil, hosts: nil, ports: nil, max_connections_per_ip: nil, eight_bit_mime: false, smtputf8: false, pipelining: false, proxy_hosts: [], add_received: false, forbid_bare_newline: true, max_message_size: DEFAULT_MAX_MESSAGE_SIZE, max_recipients: DEFAULT_MAX_RECIPIENTS, max_processings_wait: DEFAULT_MAX_PROCESSINGS_WAIT, read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT, handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT, max_session_duration: DEFAULT_MAX_SESSION_DURATION, max_auth_failures: DEFAULT_MAX_AUTH_FAILURES, max_exceptions: DEFAULT_MAX_EXCEPTIONS, io_buffer_max_size: DEFAULT_IO_BUFFER_MAX_SIZE, logger: nil, logger_severity: Logger::INFO) ⇒ Server
+host+/+port+ bind a single address. +hosts+/+ports+ (arrays or
comma-separated strings) expand to every host×port pair (multi-listen).
proxy_hosts non-empty enables PROXY from those IPs or CIDRs.
add_received prepends #received_header onto the DATA reader.
max_message_size is enforced while DATA lines are accepted from the
client (before they are offered to receive). After receive returns or
raises, drain discards remaining bytes through "." without re-applying
SIZE — one reply after the body (or a size-ceiling abort).
When proxy_hosts allowlists a peer, the per-IP connection cap is deferred
until PROXY rewrites the client address — otherwise a load balancer's
shared peer IP would cap the whole listener.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/mailsmtp/server.rb', line 83 def initialize(tls:, max_connections:, max_processings:, auth:, starttls:, host: nil, port: nil, hosts: nil, ports: nil, max_connections_per_ip: nil, eight_bit_mime: false, smtputf8: false, pipelining: false, proxy_hosts: [], add_received: false, forbid_bare_newline: true, max_message_size: DEFAULT_MAX_MESSAGE_SIZE, max_recipients: DEFAULT_MAX_RECIPIENTS, max_processings_wait: DEFAULT_MAX_PROCESSINGS_WAIT, read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT, handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT, max_session_duration: DEFAULT_MAX_SESSION_DURATION, max_auth_failures: DEFAULT_MAX_AUTH_FAILURES, max_exceptions: DEFAULT_MAX_EXCEPTIONS, io_buffer_max_size: DEFAULT_IO_BUFFER_MAX_SIZE, logger: nil, logger_severity: Logger::INFO) raise ArgumentError, "Unknown auth mode #{auth.inspect}" unless AUTH_MODES.include?(auth) raise ArgumentError, "Unknown starttls mode #{starttls.inspect}" unless STARTTLS_MODES.include?(starttls) raise ArgumentError, "max_recipients must be at least 100 (RFC 5321 §4.5.3.1)" if max_recipients < 100 raise ArgumentError, "starttls: :required requires a tls object" if starttls == :required && tls.nil? raise ArgumentError, "max_connections must be an Integer >= 1" unless max_connections.is_a?(Integer) && max_connections >= 1 raise ArgumentError, "max_processings must be an Integer >= 1" unless max_processings.is_a?(Integer) && max_processings >= 1 unless max_connections_per_ip.nil? || (max_connections_per_ip.is_a?(Integer) && max_connections_per_ip >= 1) raise ArgumentError, "max_connections_per_ip must be nil or an Integer >= 1" end unless max_auth_failures.nil? || (max_auth_failures.is_a?(Integer) && max_auth_failures >= 1) raise ArgumentError, "max_auth_failures must be nil or an Integer >= 1" end unless max_exceptions.nil? || (max_exceptions.is_a?(Integer) && max_exceptions >= 1) raise ArgumentError, "max_exceptions must be nil or an Integer >= 1" end @addresses = (hosts || host, ports || port) raise ArgumentError, "host and port (or hosts and ports) are required" if @addresses.empty? @tls = tls @max_connections = max_connections @max_connections_per_ip = max_connections_per_ip @max_processings = max_processings @max_processings_wait = max_processings_wait @auth = auth @starttls = starttls @eight_bit_mime = eight_bit_mime @smtputf8 = smtputf8 @pipelining = pipelining @forbid_bare_newline = @proxy_hosts = Array(proxy_hosts).map { |entry| IPAddr.new(entry) } @add_received = add_received @max_message_size = @max_recipients = max_recipients @read_timeout = read_timeout @write_timeout = write_timeout # STARTTLS budget is independent of +read_timeout+ (which may be nil to # disable idle timeouts). Always apply a finite handshake ceiling — # +Timeout+ alone cannot interrupt OpenSSL's blocking accept, so we also # set +IO#timeout+ around the handshake when the socket supports it. @handshake_timeout = handshake_timeout || DEFAULT_HANDSHAKE_TIMEOUT @max_session_duration = max_session_duration @max_auth_failures = max_auth_failures @max_exceptions = max_exceptions @io_buffer_max_size = io_buffer_max_size @stdout_logger = logger || build_default_logger(logger_severity) @connections = [] @connection_ips = {} @processings = [] @connections_mutex = Mutex.new @connections_cv = ConditionVariable.new @shutdown = false @tcp_servers = [] @acceptor_threads = [] end |
Instance Attribute Details
#max_connections ⇒ Object (readonly)
Returns the value of attribute max_connections.
65 66 67 |
# File 'lib/mailsmtp/server.rb', line 65 def max_connections @max_connections end |
#max_connections_per_ip ⇒ Object (readonly)
Returns the value of attribute max_connections_per_ip.
65 66 67 |
# File 'lib/mailsmtp/server.rb', line 65 def max_connections_per_ip @max_connections_per_ip end |
#max_processings ⇒ Object (readonly)
Returns the value of attribute max_processings.
65 66 67 |
# File 'lib/mailsmtp/server.rb', line 65 def max_processings @max_processings end |
#tls ⇒ Object
Reassign between STARTTLS negotiations to hot-swap certificates (e.g. ACME).
Engine paths call #tls (not @tls) so an overridden reader is honored.
68 69 70 |
# File 'lib/mailsmtp/server.rb', line 68 def tls @tls end |
Instance Method Details
#addresses ⇒ Object
190 191 192 |
# File 'lib/mailsmtp/server.rb', line 190 def addresses @addresses.map { |host, port| "#{host}:#{port}" } end |
#auth_mechanisms(_session) ⇒ Object
SASL mechanism names advertised on EHLO (250-AUTH …) and accepted by
AUTH. Default is PLAIN and LOGIN; override to narrow the list or to
advertise additional names before their handlers exist (unknown names
still reply 504 until the engine learns them).
244 245 246 |
# File 'lib/mailsmtp/server.rb', line 244 def auth_mechanisms(_session) %w[ PLAIN LOGIN ] end |
#authenticate(session, username:, password:, authorization_id: "") ⇒ Object
236 237 238 |
# File 'lib/mailsmtp/server.rb', line 236 def authenticate(session, username:, password:, authorization_id: "") raise AuthenticationFailed end |
#build_session ⇒ Object
Override to supply a Session subclass.
274 275 276 |
# File 'lib/mailsmtp/server.rb', line 274 def build_session Session.new end |
#connected(session) ⇒ Object
227 228 |
# File 'lib/mailsmtp/server.rb', line 227 def connected(session) end |
#connections? ⇒ Boolean
182 183 184 |
# File 'lib/mailsmtp/server.rb', line 182 def connections? connections_count.positive? end |
#connections_count ⇒ Object
178 179 180 |
# File 'lib/mailsmtp/server.rb', line 178 def connections_count @connections.size end |
#disconnected(session) ⇒ Object
230 231 |
# File 'lib/mailsmtp/server.rb', line 230 def disconnected(session) end |
#headers_received(session) ⇒ Object
257 258 |
# File 'lib/mailsmtp/server.rb', line 257 def headers_received(session) end |
#helo(session, name) ⇒ Object
233 234 |
# File 'lib/mailsmtp/server.rb', line 233 def helo(session, name) end |
#log(session, severity, msg, err: nil) ⇒ Object
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/mailsmtp/server.rb', line 207 def log(session, severity, msg, err: nil) if session.respond_to?(:remote_ip) && session.remote_ip msg = "[#{session.remote_ip}] #{msg}" end case severity when Logger::FATAL @stdout_logger.fatal(msg) Array(err&.backtrace).each { |line| @stdout_logger.fatal(line) } when Logger::ERROR @stdout_logger.error(msg) when Logger::WARN @stdout_logger.warn(msg) when Logger::INFO @stdout_logger.info(msg) else @stdout_logger.debug(msg) end end |
#mail_from(session, address) ⇒ Object
248 249 |
# File 'lib/mailsmtp/server.rb', line 248 def mail_from(session, address) end |
#process_line(session, line) ⇒ Object
Override for per-command policy (rate limits, session expiry, …). Call
super to keep the engine's command dispatch.
280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/mailsmtp/server.rb', line 280 def process_line(session, line) case session.phase when :auth_plain process_auth_plain(session, line) when :auth_login_user process_auth_login_username(session, line) when :auth_login_pass process_auth_login_password(session, line) else process_command_line(session, line) end end |
#processings_count ⇒ Object
186 187 188 |
# File 'lib/mailsmtp/server.rb', line 186 def processings_count @processings.size end |
#proxy(session, proxy_data) ⇒ Object
Optional: inspect or replace PROXY data after a valid PROXY line.
203 204 205 |
# File 'lib/mailsmtp/server.rb', line 203 def proxy(session, proxy_data) proxy_data end |
#rcpt_to(session, address) ⇒ Object
251 252 |
# File 'lib/mailsmtp/server.rb', line 251 def rcpt_to(session, address) end |
#receive(session, io) ⇒ Object
io is a DataReader (or PrefixedReader) of dot-unstuffed message bytes.
Prefer IO.copy_stream (or chunked read) over io.read so large mail
does not become one String. A SIZE or line-length failure raises a
MailSmtp::Error from read — do not treat a short body as success.
The engine drains unread data through "." before replying, except after
MessageTooLarge, when it replies 552 and drops the connection.
266 267 |
# File 'lib/mailsmtp/server.rb', line 266 def receive(session, io) end |
#received_header(session) ⇒ Object
Override to customize the opt-in Received header (+add_received: true+).
195 196 197 198 199 200 |
# File 'lib/mailsmtp/server.rb', line 195 def received_header(session) protocol = session.encrypted? ? (session.authenticated? ? "ESMTPSA" : "ESMTPS") : "ESMTP" helo = session.helo.to_s.empty? ? "unknown" : session.helo "Received: from #{helo} (unknown [#{session.remote_ip}]) " \ "by #{session.helo_response} with #{protocol}; #{Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S +0000")}\r\n" end |
#receiving_started(session) ⇒ Object
254 255 |
# File 'lib/mailsmtp/server.rb', line 254 def receiving_started(session) end |
#shutdown? ⇒ Boolean
293 294 295 |
# File 'lib/mailsmtp/server.rb', line 293 def shutdown? @shutdown end |
#start ⇒ Object
155 156 157 158 159 160 161 |
# File 'lib/mailsmtp/server.rb', line 155 def start raise "Service was already started" unless stopped? @shutdown = false @tcp_servers = @addresses.map { |host, port| TCPServer.new(host, port) } attach_acceptors end |
#stop(wait_seconds_before_close: 2) ⇒ Object
163 164 165 166 167 168 169 170 171 172 |
# File 'lib/mailsmtp/server.rb', line 163 def stop(wait_seconds_before_close: 2) @shutdown = true acceptors = @acceptor_threads.dup @connections_mutex.synchronize do acceptors.each { |thread| thread.raise(StopService) } end acceptors.each(&:join) drain_connections(wait_seconds_before_close) end |
#stopped? ⇒ Boolean
174 175 176 |
# File 'lib/mailsmtp/server.rb', line 174 def stopped? @acceptor_threads.empty? end |
#unknown_command(_session, _line) ⇒ Object
269 270 271 |
# File 'lib/mailsmtp/server.rb', line 269 def unknown_command(_session, _line) raise CommandNotRecognized end |