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- HELO_SPECIALS =
RFC 5322 header specials: parens (comments), backslash/quote (quoted strings), angle brackets and "@" (addr-spec), ";" (Received date delimiter), space (token boundary). Postfix neuters exactly this set.
' <>()\\";@'.freeze
- HELO_UNSAFE =
Everything outside printable US-ASCII, plus HELO_SPECIALS. Not fixed-encoding, so it applies to a BINARY subject byte by byte.
/[^\x20-\x7e]|[#{Regexp.escape(HELO_SPECIALS)}]/- HELO_UNSAFE_UTF8 =
For an SMTPUTF8 HELO: well-formed UTF-8 survives except control/format characters (\pCc\pCf — bidi overrides, zero-width joiners), non-ASCII spaces and line separators (\pZs\pZl\pZp — could smuggle whitespace or newlines past the ASCII rule), and unassigned/private-use codepoints (\pCn\pCo).
Fixed-encoding UTF-8, so unlike HELO_UNSAFE it raises Encoding::CompatibilityError on a BINARY subject with high bytes — only apply it to a valid_encoding? UTF-8 string (see
neuter_helo). /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}\p{Cn}\p{Co}]|[#{Regexp.escape(HELO_SPECIALS)}]/- 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
Verify +username+/+password+ and raise
AuthenticationFailedif they do not check out. -
#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.
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 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/mailsmtp/server.rb', line 102 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.
84 85 86 |
# File 'lib/mailsmtp/server.rb', line 84 def max_connections @max_connections end |
#max_connections_per_ip ⇒ Object (readonly)
Returns the value of attribute max_connections_per_ip.
84 85 86 |
# File 'lib/mailsmtp/server.rb', line 84 def max_connections_per_ip @max_connections_per_ip end |
#max_processings ⇒ Object (readonly)
Returns the value of attribute max_processings.
84 85 86 |
# File 'lib/mailsmtp/server.rb', line 84 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.
87 88 89 |
# File 'lib/mailsmtp/server.rb', line 87 def tls @tls end |
Instance Method Details
#addresses ⇒ Object
209 210 211 |
# File 'lib/mailsmtp/server.rb', line 209 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).
268 269 270 |
# File 'lib/mailsmtp/server.rb', line 268 def auth_mechanisms(_session) %w[ PLAIN LOGIN ] end |
#authenticate(session, username:, password:, authorization_id: "") ⇒ Object
Verify +username+/+password+ and raise AuthenticationFailed if they do
not check out. authorization_id is the SASL PLAIN authzid — an
unverified claim by the client, safely ignored by default. To honor it,
verify the account may act as it and return that identity.
260 261 262 |
# File 'lib/mailsmtp/server.rb', line 260 def authenticate(session, username:, password:, authorization_id: "") raise AuthenticationFailed end |
#build_session ⇒ Object
Override to supply a Session subclass.
298 299 300 |
# File 'lib/mailsmtp/server.rb', line 298 def build_session Session.new end |
#connected(session) ⇒ Object
247 248 |
# File 'lib/mailsmtp/server.rb', line 247 def connected(session) end |
#connections? ⇒ Boolean
201 202 203 |
# File 'lib/mailsmtp/server.rb', line 201 def connections? connections_count.positive? end |
#connections_count ⇒ Object
197 198 199 |
# File 'lib/mailsmtp/server.rb', line 197 def connections_count @connections.size end |
#disconnected(session) ⇒ Object
250 251 |
# File 'lib/mailsmtp/server.rb', line 250 def disconnected(session) end |
#headers_received(session) ⇒ Object
281 282 |
# File 'lib/mailsmtp/server.rb', line 281 def headers_received(session) end |
#helo(session, name) ⇒ Object
253 254 |
# File 'lib/mailsmtp/server.rb', line 253 def helo(session, name) end |
#log(session, severity, msg, err: nil) ⇒ Object
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/mailsmtp/server.rb', line 227 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
272 273 |
# File 'lib/mailsmtp/server.rb', line 272 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.
304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/mailsmtp/server.rb', line 304 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
205 206 207 |
# File 'lib/mailsmtp/server.rb', line 205 def processings_count @processings.size end |
#proxy(session, proxy_data) ⇒ Object
Optional: inspect or replace PROXY data after a valid PROXY line.
223 224 225 |
# File 'lib/mailsmtp/server.rb', line 223 def proxy(session, proxy_data) proxy_data end |
#rcpt_to(session, address) ⇒ Object
275 276 |
# File 'lib/mailsmtp/server.rb', line 275 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.
290 291 |
# File 'lib/mailsmtp/server.rb', line 290 def receive(session, io) end |
#received_header(session) ⇒ Object
Override to customize the opt-in Received header (+add_received: true+).
214 215 216 217 218 219 220 |
# File 'lib/mailsmtp/server.rb', line 214 def received_header(session) protocol = session.encrypted? ? (session.authenticated? ? "ESMTPSA" : "ESMTPS") : "ESMTP" helo = session.helo.to_s helo = helo.empty? ? "unknown" : ascii_only_helo(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
278 279 |
# File 'lib/mailsmtp/server.rb', line 278 def receiving_started(session) end |
#shutdown? ⇒ Boolean
317 318 319 |
# File 'lib/mailsmtp/server.rb', line 317 def shutdown? @shutdown end |
#start ⇒ Object
174 175 176 177 178 179 180 |
# File 'lib/mailsmtp/server.rb', line 174 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
182 183 184 185 186 187 188 189 190 191 |
# File 'lib/mailsmtp/server.rb', line 182 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
193 194 195 |
# File 'lib/mailsmtp/server.rb', line 193 def stopped? @acceptor_threads.empty? end |
#unknown_command(_session, _line) ⇒ Object
293 294 295 |
# File 'lib/mailsmtp/server.rb', line 293 def unknown_command(_session, _line) raise CommandNotRecognized end |