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


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



65
66
67
# File 'lib/mailsmtp/server.rb', line 65

def max_connections
  @max_connections
end

#max_connections_per_ipObject (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_processingsObject (readonly)

Returns the value of attribute max_processings.



65
66
67
# File 'lib/mailsmtp/server.rb', line 65

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.



68
69
70
# File 'lib/mailsmtp/server.rb', line 68

def tls
  @tls
end

Instance Method Details

#addressesObject



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_sessionObject

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

Returns:

  • (Boolean)


182
183
184
# File 'lib/mailsmtp/server.rb', line 182

def connections?
  connections_count.positive?
end

#connections_countObject



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

#processings_countObject



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

Returns:

  • (Boolean)


293
294
295
# File 'lib/mailsmtp/server.rb', line 293

def shutdown?
  @shutdown
end

#startObject



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

Returns:

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