Class: MailSmtp::Server

Inherits:
Object
  • Object
show all
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_timeout before 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

Instance Method Summary collapse

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.

Raises:

  • (ArgumentError)


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 = expand_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 = forbid_bare_newline
  @proxy_hosts = Array(proxy_hosts).map { |entry| IPAddr.new(entry) }
  @add_received = add_received
  @max_message_size = 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_connectionsObject (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_ipObject (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_processingsObject (readonly)

Returns the value of attribute max_processings.



84
85
86
# File 'lib/mailsmtp/server.rb', line 84

def max_processings
  @max_processings
end

#tlsObject

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

#addressesObject



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_sessionObject

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

Returns:

  • (Boolean)


201
202
203
# File 'lib/mailsmtp/server.rb', line 201

def connections?
  connections_count.positive?
end

#connections_countObject



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
      (session, line)
  when :auth_login_pass
      (session, line)
  else
      process_command_line(session, line)
  end
end

#processings_countObject



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

Returns:

  • (Boolean)


317
318
319
# File 'lib/mailsmtp/server.rb', line 317

def shutdown?
  @shutdown
end

#startObject



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

Returns:

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