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.
@@ -0,0 +1,1199 @@
1
+ require "socket"
2
+ require "openssl"
3
+ require "base64"
4
+ require "logger"
5
+ require "ipaddr"
6
+ require "timeout"
7
+
8
+ module MailSmtp
9
+ # A thread-per-connection SMTP *server* (no client). Handles the RFC 5321
10
+ # command sequence (HELO/EHLO, AUTH PLAIN/LOGIN, MAIL FROM, RCPT TO, DATA,
11
+ # STARTTLS, RSET, NOOP, QUIT, VRFY), leaving domain policy to subclass hooks.
12
+ # SIZE is advertised and enforced when +max_message_size+ is set; 8BITMIME,
13
+ # SMTPUTF8, PIPELINING, PROXY, and multi-listen are optional.
14
+ # STARTTLS always drops any plaintext still buffered after the handshake
15
+ # (CVE-2011-0411). Implicit TLS (port 465) is not supported yet.
16
+ #
17
+ # Policy is subclass verbs (+authenticate+, +mail_from+, +rcpt_to+, +receive+).
18
+ # During DATA the engine passes a live +DataReader+ into +receive(session, io)+
19
+ # (dot-unstuffed socket bytes); the app reads or discards, then the engine
20
+ # drains to "." and replies once.
21
+ #
22
+ # +tls+ is any object that responds to +#start(io)+ and returns an SSL socket,
23
+ # or +nil+ to disable STARTTLS. Reassign +tls+ to hot-swap certificates.
24
+ class Server
25
+ AUTH_MODES = %i[ disabled required ].freeze
26
+ STARTTLS_MODES = %i[ optional required ].freeze
27
+
28
+ # RFC 4954 §4: AUTH LOGIN prompts with base64 "Username:" then "Password:".
29
+ AUTH_LOGIN_USERNAME_PROMPT = "334 #{Base64.strict_encode64("Username:")}".freeze
30
+ AUTH_LOGIN_PASSWORD_PROMPT = "334 #{Base64.strict_encode64("Password:")}".freeze
31
+
32
+ IO_BUFFER_CHUNK_SIZE = 4 * 1024
33
+ IO_WAIT_TIMEOUT = 1
34
+ DEFAULT_IO_BUFFER_MAX_SIZE = 1 * 1024 * 1024
35
+ # RFC 5321 §4.5.3.2.7: servers should wait at least 5 minutes for the next
36
+ # client command. (The earlier §4.5.3.2.1–.6 times are client-side.)
37
+ DEFAULT_READ_TIMEOUT = 300
38
+ DEFAULT_WRITE_TIMEOUT = 30
39
+ DEFAULT_HANDSHAKE_TIMEOUT = 30
40
+ DEFAULT_MAX_PROCESSINGS_WAIT = 30
41
+ DEFAULT_MAX_RECIPIENTS = 100
42
+ DEFAULT_MAX_MESSAGE_SIZE = 10_485_760 # 10 MiB
43
+ # Absolute ceiling so idle resets on each line cannot stretch a session forever.
44
+ DEFAULT_MAX_SESSION_DURATION = 1800
45
+ DEFAULT_MAX_AUTH_FAILURES = 3
46
+ DEFAULT_MAX_EXCEPTIONS = 20
47
+ # Short write budget for a 421 abort after a normal write already timed out,
48
+ # so we do not stack another full +write_timeout+ before dropping the socket.
49
+ ABORT_REPLY_WRITE_TIMEOUT = 2
50
+ # RFC 5321 §4.5.3.1.4 / §4.5.3.1.6 (octets, including the CRLF).
51
+ MAX_COMMAND_LINE_LENGTH = 512
52
+ MAX_TEXT_LINE_LENGTH = 1000
53
+ # RFC 4954 §4: AUTH base64 responses (commands and continuations) may be
54
+ # up to 12288 octets — not the 512-octet SMTP command-line ceiling.
55
+ MAX_AUTH_LINE_LENGTH = 12_288
56
+
57
+ MAX_FAILED_ACCEPTS = 10
58
+ FAILED_ACCEPT_BACKOFF = 0.5
59
+
60
+ StopService = Class.new(RuntimeError)
61
+ StopConnection = Class.new(RuntimeError)
62
+ BufferOverrun = Class.new(RuntimeError)
63
+ private_constant :StopService, :StopConnection, :BufferOverrun
64
+
65
+ attr_reader :max_connections, :max_connections_per_ip, :max_processings
66
+ # Reassign between STARTTLS negotiations to hot-swap certificates (e.g. ACME).
67
+ # Engine paths call +#tls+ (not +@tls+) so an overridden reader is honored.
68
+ attr_accessor :tls
69
+
70
+ # +host+/+port+ bind a single address. +hosts+/+ports+ (arrays or
71
+ # comma-separated strings) expand to every host×port pair (multi-listen).
72
+ # +proxy_hosts+ non-empty enables PROXY from those IPs or CIDRs.
73
+ # +add_received+ prepends {#received_header} onto the DATA reader.
74
+ #
75
+ # +max_message_size+ is enforced while DATA lines are accepted from the
76
+ # client (before they are offered to +receive+). After +receive+ returns or
77
+ # raises, +drain+ discards remaining bytes through "." without re-applying
78
+ # SIZE — one reply after the body (or a size-ceiling abort).
79
+ #
80
+ # When +proxy_hosts+ allowlists a peer, the per-IP connection cap is deferred
81
+ # until PROXY rewrites the client address — otherwise a load balancer's
82
+ # shared peer IP would cap the whole listener.
83
+ def initialize(tls:, max_connections:, max_processings:, auth:, starttls:,
84
+ host: nil, port: nil, hosts: nil, ports: nil,
85
+ max_connections_per_ip: nil, eight_bit_mime: false, smtputf8: false,
86
+ pipelining: false, proxy_hosts: [], add_received: false,
87
+ forbid_bare_newline: true,
88
+ max_message_size: DEFAULT_MAX_MESSAGE_SIZE, max_recipients: DEFAULT_MAX_RECIPIENTS,
89
+ max_processings_wait: DEFAULT_MAX_PROCESSINGS_WAIT,
90
+ read_timeout: DEFAULT_READ_TIMEOUT, write_timeout: DEFAULT_WRITE_TIMEOUT,
91
+ handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT,
92
+ max_session_duration: DEFAULT_MAX_SESSION_DURATION,
93
+ max_auth_failures: DEFAULT_MAX_AUTH_FAILURES,
94
+ max_exceptions: DEFAULT_MAX_EXCEPTIONS,
95
+ io_buffer_max_size: DEFAULT_IO_BUFFER_MAX_SIZE,
96
+ logger: nil, logger_severity: Logger::INFO)
97
+ raise ArgumentError, "Unknown auth mode #{auth.inspect}" unless AUTH_MODES.include?(auth)
98
+ raise ArgumentError, "Unknown starttls mode #{starttls.inspect}" unless STARTTLS_MODES.include?(starttls)
99
+ raise ArgumentError, "max_recipients must be at least 100 (RFC 5321 §4.5.3.1)" if max_recipients < 100
100
+ raise ArgumentError, "starttls: :required requires a tls object" if starttls == :required && tls.nil?
101
+ raise ArgumentError, "max_connections must be an Integer >= 1" unless max_connections.is_a?(Integer) && max_connections >= 1
102
+ raise ArgumentError, "max_processings must be an Integer >= 1" unless max_processings.is_a?(Integer) && max_processings >= 1
103
+ unless max_connections_per_ip.nil? || (max_connections_per_ip.is_a?(Integer) && max_connections_per_ip >= 1)
104
+ raise ArgumentError, "max_connections_per_ip must be nil or an Integer >= 1"
105
+ end
106
+ unless max_auth_failures.nil? || (max_auth_failures.is_a?(Integer) && max_auth_failures >= 1)
107
+ raise ArgumentError, "max_auth_failures must be nil or an Integer >= 1"
108
+ end
109
+ unless max_exceptions.nil? || (max_exceptions.is_a?(Integer) && max_exceptions >= 1)
110
+ raise ArgumentError, "max_exceptions must be nil or an Integer >= 1"
111
+ end
112
+
113
+ @addresses = expand_addresses(hosts || host, ports || port)
114
+ raise ArgumentError, "host and port (or hosts and ports) are required" if @addresses.empty?
115
+
116
+ @tls = tls
117
+ @max_connections = max_connections
118
+ @max_connections_per_ip = max_connections_per_ip
119
+ @max_processings = max_processings
120
+ @max_processings_wait = max_processings_wait
121
+ @auth = auth
122
+ @starttls = starttls
123
+ @eight_bit_mime = eight_bit_mime
124
+ @smtputf8 = smtputf8
125
+ @pipelining = pipelining
126
+ @forbid_bare_newline = forbid_bare_newline
127
+ @proxy_hosts = Array(proxy_hosts).map { |entry| IPAddr.new(entry) }
128
+ @add_received = add_received
129
+ @max_message_size = max_message_size
130
+ @max_recipients = max_recipients
131
+ @read_timeout = read_timeout
132
+ @write_timeout = write_timeout
133
+ # STARTTLS budget is independent of +read_timeout+ (which may be nil to
134
+ # disable idle timeouts). Always apply a finite handshake ceiling —
135
+ # +Timeout+ alone cannot interrupt OpenSSL's blocking accept, so we also
136
+ # set +IO#timeout+ around the handshake when the socket supports it.
137
+ @handshake_timeout = handshake_timeout || DEFAULT_HANDSHAKE_TIMEOUT
138
+ @max_session_duration = max_session_duration
139
+ @max_auth_failures = max_auth_failures
140
+ @max_exceptions = max_exceptions
141
+ @io_buffer_max_size = io_buffer_max_size
142
+
143
+ @stdout_logger = logger || build_default_logger(logger_severity)
144
+
145
+ @connections = []
146
+ @connection_ips = {}
147
+ @processings = []
148
+ @connections_mutex = Mutex.new
149
+ @connections_cv = ConditionVariable.new
150
+ @shutdown = false
151
+ @tcp_servers = []
152
+ @acceptor_threads = []
153
+ end
154
+
155
+ def start
156
+ raise "Service was already started" unless stopped?
157
+
158
+ @shutdown = false
159
+ @tcp_servers = @addresses.map { |host, port| TCPServer.new(host, port) }
160
+ attach_acceptors
161
+ end
162
+
163
+ def stop(wait_seconds_before_close: 2)
164
+ @shutdown = true
165
+
166
+ acceptors = @acceptor_threads.dup
167
+ @connections_mutex.synchronize do
168
+ acceptors.each { |thread| thread.raise(StopService) }
169
+ end
170
+ acceptors.each(&:join)
171
+ drain_connections(wait_seconds_before_close)
172
+ end
173
+
174
+ def stopped?
175
+ @acceptor_threads.empty?
176
+ end
177
+
178
+ def connections_count
179
+ @connections.size
180
+ end
181
+
182
+ def connections?
183
+ connections_count.positive?
184
+ end
185
+
186
+ def processings_count
187
+ @processings.size
188
+ end
189
+
190
+ def addresses
191
+ @addresses.map { |host, port| "#{host}:#{port}" }
192
+ end
193
+
194
+ # Override to customize the opt-in Received header (+add_received: true+).
195
+ def received_header(session)
196
+ protocol = session.encrypted? ? (session.authenticated? ? "ESMTPSA" : "ESMTPS") : "ESMTP"
197
+ helo = session.helo.to_s.empty? ? "unknown" : session.helo
198
+ "Received: from #{helo} (unknown [#{session.remote_ip}]) " \
199
+ "by #{session.helo_response} with #{protocol}; #{Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S +0000")}\r\n"
200
+ end
201
+
202
+ # Optional: inspect or replace PROXY data after a valid PROXY line.
203
+ def proxy(session, proxy_data)
204
+ proxy_data
205
+ end
206
+
207
+ def log(session, severity, msg, err: nil)
208
+ if session.respond_to?(:remote_ip) && session.remote_ip
209
+ msg = "[#{session.remote_ip}] #{msg}"
210
+ end
211
+
212
+ case severity
213
+ when Logger::FATAL
214
+ @stdout_logger.fatal(msg)
215
+ Array(err&.backtrace).each { |line| @stdout_logger.fatal(line) }
216
+ when Logger::ERROR
217
+ @stdout_logger.error(msg)
218
+ when Logger::WARN
219
+ @stdout_logger.warn(msg)
220
+ when Logger::INFO
221
+ @stdout_logger.info(msg)
222
+ else
223
+ @stdout_logger.debug(msg)
224
+ end
225
+ end
226
+
227
+ def connected(session)
228
+ end
229
+
230
+ def disconnected(session)
231
+ end
232
+
233
+ def helo(session, name)
234
+ end
235
+
236
+ def authenticate(session, username:, password:, authorization_id: "")
237
+ raise AuthenticationFailed
238
+ end
239
+
240
+ # SASL mechanism names advertised on EHLO (`250-AUTH …`) and accepted by
241
+ # +AUTH+. Default is PLAIN and LOGIN; override to narrow the list or to
242
+ # advertise additional names before their handlers exist (unknown names
243
+ # still reply 504 until the engine learns them).
244
+ def auth_mechanisms(_session)
245
+ %w[ PLAIN LOGIN ]
246
+ end
247
+
248
+ def mail_from(session, address)
249
+ end
250
+
251
+ def rcpt_to(session, address)
252
+ end
253
+
254
+ def receiving_started(session)
255
+ end
256
+
257
+ def headers_received(session)
258
+ end
259
+
260
+ # +io+ is a +DataReader+ (or +PrefixedReader+) of dot-unstuffed message bytes.
261
+ # Prefer +IO.copy_stream+ (or chunked +read+) over +io.read+ so large mail
262
+ # does not become one String. A SIZE or line-length failure raises a
263
+ # +MailSmtp::Error+ from +read+ — do not treat a short body as success.
264
+ # The engine drains unread data through "." before replying, except after
265
+ # +MessageTooLarge+, when it replies 552 and drops the connection.
266
+ def receive(session, io)
267
+ end
268
+
269
+ def unknown_command(_session, _line)
270
+ raise CommandNotRecognized
271
+ end
272
+
273
+ # Override to supply a Session subclass.
274
+ def build_session
275
+ Session.new
276
+ end
277
+
278
+ # Override for per-command policy (rate limits, session expiry, …). Call
279
+ # +super+ to keep the engine's command dispatch.
280
+ def process_line(session, line)
281
+ case session.phase
282
+ when :auth_plain
283
+ process_auth_plain(session, line)
284
+ when :auth_login_user
285
+ process_auth_login_username(session, line)
286
+ when :auth_login_pass
287
+ process_auth_login_password(session, line)
288
+ else
289
+ process_command_line(session, line)
290
+ end
291
+ end
292
+
293
+ def shutdown?
294
+ @shutdown
295
+ end
296
+
297
+ private
298
+ def build_default_logger(severity)
299
+ Logger.new($stdout).tap do |logger|
300
+ logger.level = severity
301
+ logger.datetime_format = "%Y-%m-%d %H:%M:%S"
302
+ logger.formatter = proc { |sev, datetime, _progname, msg| "#{datetime}: [#{sev}] #{msg.to_s.chomp}\n" }
303
+ end
304
+ end
305
+
306
+ def expand_addresses(hosts, ports)
307
+ host_list = Array(hosts).flat_map { |value| value.to_s.split(/[,\s]+/) }.map(&:strip).reject(&:empty?)
308
+ port_list = Array(ports).flat_map { |value| value.to_s.split(/[,\s:]+/) }.map(&:strip).reject(&:empty?).map { |port| Integer(port) }
309
+ host_list.product(port_list)
310
+ end
311
+
312
+ def attach_acceptors
313
+ @acceptor_threads = @tcp_servers.map do |tcp_server|
314
+ Thread.new { accept_loop(tcp_server) }
315
+ end
316
+ end
317
+
318
+ def proxy_enabled?
319
+ @proxy_hosts.any?
320
+ end
321
+
322
+ def proxy_allowed?(remote_ip)
323
+ return false unless proxy_enabled?
324
+ return false if remote_ip.nil?
325
+
326
+ @proxy_hosts.any? { |allowed| allowed.include?(remote_ip) }
327
+ end
328
+
329
+ # A failed accept is usually the machine's problem, not ours — a client that
330
+ # vanished mid-handshake, or no file descriptors left for a moment — so we
331
+ # log it and keep listening. Only a run of failures long enough to look
332
+ # permanent takes the listener down, and then the supervisor restarts it.
333
+ def accept_loop(tcp_server)
334
+ failed_accepts = 0
335
+
336
+ until shutdown?
337
+ begin
338
+ client = tcp_server.accept
339
+ failed_accepts = 0
340
+ Thread.new(client) { |io| handle_connection(io) }
341
+ rescue StopService
342
+ raise
343
+ rescue StandardError => e
344
+ failed_accepts += 1
345
+ raise if failed_accepts > MAX_FAILED_ACCEPTS
346
+
347
+ log(nil, Logger::ERROR, "Can't accept a connection (#{failed_accepts}/#{MAX_FAILED_ACCEPTS}): #{loggable_error(e)}")
348
+ sleep FAILED_ACCEPT_BACKOFF
349
+ end
350
+ end
351
+ rescue StopService
352
+ # signaled by #stop, not an error
353
+ rescue StandardError => e
354
+ log(nil, Logger::FATAL, loggable_error(e), err: e)
355
+ ensure
356
+ close_tcp_server(tcp_server)
357
+ @acceptor_threads.delete(Thread.current)
358
+ end
359
+
360
+ # Give in-flight connections a bounded moment to finish on their own (a
361
+ # delivery is a quick DB transaction), then force any stragglers and wait
362
+ # for them to unwind, so we never exit the process mid-message.
363
+ def drain_connections(grace_seconds)
364
+ deadline = monotonic_time + grace_seconds
365
+ sleep 0.05 while connections? && monotonic_time < deadline
366
+
367
+ stragglers = @connections.dup
368
+ stragglers.each { |thread| thread.raise(StopConnection) }
369
+ stragglers.each(&:join)
370
+ end
371
+
372
+ def close_tcp_server(tcp_server)
373
+ tcp_server.close
374
+ rescue StandardError
375
+ # already gone
376
+ end
377
+
378
+ # Keyed by thread so a slot is released exactly where the connection is: a
379
+ # count that drifted upward would leave the listener refusing everyone.
380
+ def handle_connection(io)
381
+ begin
382
+ session = build_session
383
+ io = serve_client(session, io)
384
+ rescue StopConnection
385
+ # Forced stop after the grace window — RST so the peer does not sit
386
+ # half-open waiting on a bare FIN.
387
+ abort_socket!(io)
388
+ io = nil
389
+ rescue StandardError => e
390
+ log(nil, Logger::FATAL, loggable_error(e), err: e)
391
+ ensure
392
+ begin
393
+ if io
394
+ abort_socket!(io) if shutdown?
395
+ io.close unless io.closed?
396
+ end
397
+ rescue StandardError
398
+ # already gone
399
+ end
400
+
401
+ release_connection_slot!
402
+ end
403
+ end
404
+
405
+ # Register this thread under +remote_ip+ and enforce global / per-IP caps in
406
+ # the same critical section. Re-calling with a new IP (PROXY) updates the
407
+ # identity and re-checks the per-IP cap against the rewritten address.
408
+ # Pass +enforce_per_ip: false+ for an allowlisted PROXY peer before the
409
+ # PROXY line rewrites the client — the load balancer IP must not consume
410
+ # the per-IP budget for every tenant behind it.
411
+ def claim_connection_slot!(remote_ip, enforce_per_ip: true)
412
+ @connections_mutex.synchronize do
413
+ if @connections.include?(Thread.current)
414
+ old_ip = @connection_ips[Thread.current]
415
+ if old_ip != remote_ip
416
+ if enforce_per_ip && max_connections_per_ip && remote_ip &&
417
+ @connection_ips.values.count(remote_ip) >= max_connections_per_ip
418
+ raise ServiceUnavailable, "Too many connections from #{remote_ip}"
419
+ end
420
+ @connection_ips[Thread.current] = remote_ip
421
+ end
422
+ else
423
+ if max_connections && @connections.size >= max_connections
424
+ raise ServiceUnavailable, "Abort connection while too busy, exceeding max_connections!"
425
+ end
426
+ if enforce_per_ip && max_connections_per_ip && remote_ip &&
427
+ @connection_ips.values.count(remote_ip) >= max_connections_per_ip
428
+ raise ServiceUnavailable, "Too many connections from #{remote_ip}"
429
+ end
430
+ @connections << Thread.current
431
+ @connection_ips[Thread.current] = remote_ip
432
+ end
433
+ end
434
+ end
435
+
436
+ def release_connection_slot!
437
+ @connections_mutex.synchronize do
438
+ @connections.delete(Thread.current)
439
+ @connection_ips.delete(Thread.current)
440
+ # Safety: a mid-DATA abort must not leave a processing slot stuck.
441
+ @processings.delete(Thread.current)
442
+ @connections_cv.signal
443
+ end
444
+ end
445
+
446
+ # Processing slots gate concurrent DATA transfers only — idle/envelope
447
+ # sessions do not consume one.
448
+ def claim_processing_slot!
449
+ @connections_mutex.synchronize do
450
+ deadline = monotonic_time + @max_processings_wait
451
+ until processings_count < max_processings
452
+ remaining = deadline - monotonic_time
453
+ raise ServiceUnavailable, "Abort connection while too busy, exceeding max_processings!" if remaining <= 0
454
+
455
+ @connections_cv.wait(@connections_mutex, remaining)
456
+ end
457
+ @processings << Thread.current
458
+ end
459
+ end
460
+
461
+ def release_processing_slot!
462
+ @connections_mutex.synchronize do
463
+ @processings.delete(Thread.current)
464
+ @connections_cv.signal
465
+ end
466
+ end
467
+
468
+ # A socket the client has already abandoned has no peer to name; claim under
469
+ # a nil IP (counts only against the global cap).
470
+ def serve_client(session, io)
471
+ notified_connected = false
472
+ begin
473
+ session.local_ip = io.addr(:numeric)[3]
474
+ session.remote_ip = io.peeraddr(:numeric)[3]
475
+ session.connected_at = Time.now.utc
476
+ session.local_response = "#{session.local_ip} ESMTP"
477
+ session.helo_response = session.local_ip
478
+
479
+ claim_connection_slot!(session.remote_ip, enforce_per_ip: !proxy_allowed?(session.remote_ip))
480
+ connected(session)
481
+ notified_connected = true
482
+
483
+ # Greeting is before EHLO advertises ENHANCEDSTATUSCODES — plain 220.
484
+ respond(io, session, reply(220, session.local_response.to_s.strip, enhanced: nil))
485
+
486
+ io_buffer = +""
487
+ io_buffer_line_lf = nil
488
+ idle_since = monotonic_time
489
+ session_deadline = @max_session_duration && (idle_since + @max_session_duration)
490
+
491
+ loop do
492
+ if session.phase == :starttls
493
+ begin
494
+ io = with_handshake_timeout(io) { tls.start(io) }
495
+ rescue Timeout::Error, IO::TimeoutError
496
+ raise ServiceUnavailable, "STARTTLS timed out"
497
+ rescue StandardError => e
498
+ log(session, Logger::ERROR, loggable_error(e), err: e)
499
+ return io
500
+ end
501
+
502
+ # RFC 3207 §4.2: after the handshake, discard everything the client
503
+ # told us in plaintext (HELO, envelope, AUTH) and require a fresh HELO.
504
+ session.reset
505
+ session.reset_authentication
506
+ session.helo = nil
507
+ session.encrypted_at = Time.now.utc
508
+ record_tls_details!(session, io)
509
+ session.phase = :helo
510
+ idle_since = monotonic_time
511
+
512
+ # Bytes still buffered here arrived in the clear behind STARTTLS, so
513
+ # a MITM could have written them. Drop them rather than let them be
514
+ # read back as commands from inside the TLS session (CVE-2011-0411).
515
+ io_buffer.clear
516
+ io_buffer_line_lf = nil
517
+ end
518
+
519
+ begin
520
+ unless io_buffer_line_lf
521
+ raise_if_session_expired!(session_deadline)
522
+ raise_if_idle_timeout!(idle_since)
523
+ io_buffer << io.read_nonblock(IO_BUFFER_CHUNK_SIZE)
524
+ raise BufferOverrun if @io_buffer_max_size && io_buffer.length > @io_buffer_max_size
525
+ io_buffer_line_lf = io_buffer.index("\n")
526
+ end
527
+ # No data ready: block on the socket instead of busy-polling. We only
528
+ # get here once read_nonblock has drained any bytes SSLSocket buffered
529
+ # internally, so waiting on the raw fd is safe. A TLS renegotiation can
530
+ # ask to write mid-read, hence the WaitWritable arm.
531
+ rescue IO::WaitReadable
532
+ raise_if_session_expired!(session_deadline)
533
+ raise_if_idle_timeout!(idle_since)
534
+ IO.select([ io.to_io ], nil, nil, IO_WAIT_TIMEOUT)
535
+ rescue IO::WaitWritable
536
+ raise_if_session_expired!(session_deadline)
537
+ raise_if_idle_timeout!(idle_since)
538
+ IO.select(nil, [ io.to_io ], nil, IO_WAIT_TIMEOUT)
539
+ end
540
+
541
+ while io_buffer_line_lf
542
+ line = io_buffer.slice!(0, io_buffer_line_lf + 1)
543
+ io_buffer_line_lf = io_buffer.index("\n")
544
+ command_ok = false
545
+
546
+ begin
547
+ raise_if_session_expired!(session_deadline)
548
+ raise_if_idle_timeout!(idle_since)
549
+ raise PipeliningNotSupported if io_buffer_line_lf && !pipelining_allowed?(session)
550
+
551
+ # Commands get every CR and LF removed, not just the terminator,
552
+ # so a bare CR can't ride inside an address into the envelope.
553
+ line.delete!("\r\n")
554
+ max_line = auth_line_length?(session, line) ? MAX_AUTH_LINE_LENGTH : MAX_COMMAND_LINE_LENGTH
555
+ raise InvalidParameters, "Command line too long" if line.bytesize + 2 > max_line
556
+ log(session, Logger::DEBUG, "<<< #{loggable_command(session, line)}\n")
557
+
558
+ output = process_line(session, line)
559
+
560
+ # After 354, pull the body with a live reader into +receive+, then
561
+ # one final reply — no SMTP responses mid-DATA.
562
+ if session.phase == :data
563
+ respond(io, session, output) if output && !output.empty?
564
+ output = transfer_data(session, io, io_buffer, session_deadline: session_deadline)
565
+ io_buffer_line_lf = io_buffer.index("\n")
566
+ end
567
+
568
+ respond(io, session, output) if output && !output.empty?
569
+ command_ok = true
570
+ break if session.close_after_reply
571
+ rescue ServiceUnavailable
572
+ # 421 aborts the connection: re-raise past the command loop so the
573
+ # outer handler sends a single closing reply and drops the socket.
574
+ raise
575
+ rescue PipeliningNotSupported => e
576
+ session.exceptions += 1
577
+ log(session, Logger::ERROR, loggable_error(e), err: e)
578
+ respond(io, session, e.response)
579
+ # Drop the pipelined continuation still buffered (e.g. an AUTH
580
+ # credential) so it isn't re-read, logged unredacted, and run out
581
+ # of sequence. Same reasoning as the STARTTLS buffer drop above.
582
+ io_buffer.clear
583
+ io_buffer_line_lf = nil
584
+ raise_if_exception_caps!(session, e)
585
+ rescue Error => e
586
+ session.exceptions += 1
587
+ session.auth_failures += 1 if e.is_a?(AuthenticationFailed)
588
+ log(session, Logger::ERROR, loggable_error(e), err: e)
589
+ respond(io, session, e.response)
590
+ raise_if_exception_caps!(session, e)
591
+ rescue StandardError => e
592
+ session.exceptions += 1
593
+ log(session, Logger::ERROR, loggable_error(e), err: e)
594
+ respond(io, session, LocalError.new.response)
595
+ raise_if_exception_caps!(session, e)
596
+ end
597
+
598
+ # Idle timer advances only on a fully successful command — errors and
599
+ # probes must not keep a slowloris session alive forever by themselves.
600
+ idle_since = monotonic_time if command_ok
601
+
602
+ # STARTTLS and QUIT end the plaintext conversation, so anything the
603
+ # client pipelined behind them must not be run as if it belonged to
604
+ # the session that follows. Hand back to the outer loop instead.
605
+ break if %i[ starttls quit ].include?(session.phase)
606
+ end
607
+
608
+ break if session.phase == :quit || session.close_after_reply || io.closed? || shutdown?
609
+ end
610
+
611
+ respond(io, session, reply(221, "Service closing transmission channel")) unless session.close_after_reply
612
+ rescue StopConnection, StopService
613
+ raise
614
+ rescue EOFError => e
615
+ log(session, Logger::DEBUG, "Connection lost due abort by client! (EOFError)", err: e)
616
+ rescue ServiceUnavailable => e
617
+ session.exceptions += 1
618
+ log(session, Logger::ERROR, loggable_error(e), err: e)
619
+ begin
620
+ respond(io, session, e.response, abort_reply: true)
621
+ rescue ServiceUnavailable, StandardError => send_error
622
+ log(session, Logger::DEBUG, "Can't send 421 abort code! (#{send_error.class})", err: send_error)
623
+ end
624
+ rescue StandardError => e
625
+ session.exceptions += 1
626
+ log(session, Logger::ERROR, loggable_error(e), err: e)
627
+ begin
628
+ respond(io, session, ServiceUnavailable.new.response, abort_reply: true)
629
+ rescue ServiceUnavailable, StandardError => send_error
630
+ log(session, Logger::DEBUG, "Can't send 421 abort code! (#{send_error.class})", err: send_error)
631
+ end
632
+ ensure
633
+ disconnected(session) if notified_connected
634
+ end
635
+
636
+ io
637
+ end
638
+
639
+ def connections_from(remote_ip)
640
+ @connections_mutex.synchronize { @connection_ips.values.count(remote_ip) }
641
+ end
642
+
643
+ # Message lines lose only their terminator. Bare LF is rejected when
644
+ # +forbid_bare_newline+ is set (default); see +read_data_line!+.
645
+ def strip_line_terminator(line)
646
+ line.sub!(/\r?\n\z/, "")
647
+ end
648
+
649
+ # Mask credentials before they reach the DEBUG log: an AUTH value-state line
650
+ # is the secret itself, and an inline "AUTH PLAIN/LOGIN <arg>" carries it in
651
+ # the argument. Everything else logs verbatim.
652
+ def loggable_command(session, line)
653
+ if auth_value_phase?(session.phase)
654
+ "[redacted credential]"
655
+ else
656
+ line.sub(/\A(AUTH\s+\S+)\s+\S.*\z/i, '\1 [redacted]')
657
+ end
658
+ end
659
+
660
+ def auth_value_phase?(phase)
661
+ %i[ auth_plain auth_login_user auth_login_pass ].include?(phase)
662
+ end
663
+
664
+ # Most protocol errors are raised bare, because the class is the whole
665
+ # explanation — and a bare exception's message *is* its class name, so the
666
+ # usual "message (Class)" form prints it twice. Name the class once when
667
+ # that is all there is to say. The remote IP is left to +log+, which
668
+ # prefixes every line with it.
669
+ def loggable_error(error)
670
+ message = error.message.to_s
671
+ message == error.class.name ? message : "#{message} (#{error.class})"
672
+ end
673
+
674
+ def respond(io, session, output, abort_reply: false)
675
+ log(session, Logger::DEBUG, ">>> #{output}")
676
+ return if io.closed?
677
+
678
+ write_timeout = if abort_reply
679
+ [ @write_timeout, ABORT_REPLY_WRITE_TIMEOUT ].compact.min
680
+ else
681
+ @write_timeout
682
+ end
683
+
684
+ if write_timeout
685
+ Timeout.timeout(write_timeout) { io.print("#{output}\r\n") }
686
+ else
687
+ io.print("#{output}\r\n")
688
+ end
689
+ rescue Timeout::Error, IO::TimeoutError
690
+ raise ServiceUnavailable, "Write timed out"
691
+ end
692
+
693
+ # Success and informational replies. Pass +enhanced: nil+ for replies that
694
+ # omit the RFC 2034 status (greeting, EHLO multilines, 354, AUTH 334).
695
+ def reply(code, text, enhanced: "2.0.0")
696
+ if enhanced.nil?
697
+ "#{code} #{text}"
698
+ else
699
+ "#{code} #{enhanced} #{text}"
700
+ end
701
+ end
702
+
703
+ def process_command_line(session, line)
704
+ case line
705
+ when /\A(HELO|EHLO)(\s+.*)?\z/i
706
+ process_helo(session, line)
707
+ when /\APROXY(\s+)/i
708
+ process_proxy(session, line)
709
+ when /\ASTARTTLS\s*\z/i
710
+ process_starttls(session)
711
+ # The initial response is framed loosely and validated where it is
712
+ # decoded: the continuation form arrives on a bare line no regex guards,
713
+ # so decode_base64 is already the one gate on that path, and keeping a
714
+ # second copy of the base64 alphabet here would only let the two drift.
715
+ when /\AAUTH(\s+)(\S+)(\s+\S+)?\s*\z/i
716
+ process_auth(session, line)
717
+ when /\ANOOP(\s+.*)?\z/i
718
+ reply(250, "OK")
719
+ when /\AVRFY(\s+.*)?\z/i
720
+ # RFC 5321 §3.5 / §4.5.1: always implement VRFY; 252 is the safe
721
+ # answer when we will not confirm mailbox existence.
722
+ reply(252, "Cannot VRFY user, but will accept message and attempt delivery", enhanced: "2.5.0")
723
+ when /\AEXPN(\s+.*)?\z/i
724
+ raise CommandNotImplemented
725
+ when /\AHELP(\s+.*)?\z/i
726
+ raise CommandNotImplemented
727
+ when /\ARSET\s*\z/i
728
+ process_rset(session)
729
+ when /\AQUIT\s*\z/i
730
+ session.phase = :quit
731
+ ""
732
+ when /\AMAIL FROM:/i
733
+ process_mail_from(session, line)
734
+ when /\ARCPT TO:/i
735
+ process_rcpt_to(session, line)
736
+ when /\ADATA\s*\z/i
737
+ process_data_start(session)
738
+ else
739
+ unknown_command(session, line)
740
+ end
741
+ end
742
+
743
+ def process_helo(session, line)
744
+ # Mid-AUTH / mid-DATA are not a fresh greeting opportunity.
745
+ raise BadSequence if auth_value_phase?(session.phase) || session.phase == :data
746
+
747
+ # Bare HELO/EHLO (no argument) must not treat the verb as the hostname.
748
+ match = line.match(/\A(HELO|EHLO)(?:\s+(.*))?\z/i)
749
+ name = match[2].to_s.strip
750
+ raise InvalidParameters if name.empty?
751
+
752
+ # RFC 5321 §4.1.4: a later EHLO/HELO resets state exactly as RSET.
753
+ session.reset if session.phase != :helo
754
+
755
+ helo(session, name)
756
+ session.helo = name
757
+ session.phase = :ready
758
+
759
+ if line.match?(/\AEHLO/i)
760
+ [
761
+ "250-#{session.helo_response.to_s.strip}",
762
+ "250-ENHANCEDSTATUSCODES",
763
+ (@max_message_size ? "250-SIZE #{@max_message_size}" : nil),
764
+ (@eight_bit_mime ? "250-8BITMIME" : nil),
765
+ (@smtputf8 ? "250-SMTPUTF8" : nil),
766
+ (@pipelining ? "250-PIPELINING" : nil),
767
+ (auth_ehlo_line(session)),
768
+ (starttls_available?(session) ? "250-STARTTLS" : nil),
769
+ # EHLO multilines omit enhanced codes.
770
+ reply(250, "OK", enhanced: nil)
771
+ ].compact.join("\r\n")
772
+ else
773
+ reply(250, session.helo_response.to_s.strip)
774
+ end
775
+ end
776
+
777
+ def process_proxy(session, line)
778
+ raise CommandNotRecognized unless proxy_enabled?
779
+ raise BadSequence if session.phase != :helo
780
+ raise ServiceUnavailable, "Abort connection while PROXY not allowed from #{session.remote_ip}" unless proxy_allowed?(session.remote_ip)
781
+ raise ServiceUnavailable, "Abort connection while PROXY already set!" if session.proxy
782
+ raise ServiceUnavailable, "Abort connection while illegal PROXY command!" unless line.match?(
783
+ /\APROXY(\s+)(UNKNOWN(|(\s+).*)|TCP(4|6)(\s+)([0-9a-f.:]+)(\s+)([0-9a-f.:]+)(\s+)([0-9]+)(\s+)([0-9]+)\s*)\z/i
784
+ )
785
+
786
+ parts = line.sub(/\APROXY\s+/i, "").strip.split(/\s+/)
787
+ proxy_data = { proto: parts[0].upcase, source_ip: nil, source_port: nil, dest_ip: nil, dest_port: nil }
788
+
789
+ unless proxy_data[:proto] == "UNKNOWN"
790
+ begin
791
+ proxy_data[:source_ip] = IPAddr.new(parts[1])
792
+ proxy_data[:dest_ip] = IPAddr.new(parts[2])
793
+ proxy_data[:source_port] = Integer(parts[3])
794
+ proxy_data[:dest_port] = Integer(parts[4])
795
+ raise unless proxy_data[:source_port].between?(1, 65_535)
796
+ raise unless proxy_data[:dest_port].between?(1, 65_535)
797
+
798
+ if proxy_data[:proto] == "TCP4"
799
+ raise unless proxy_data[:source_ip].ipv4? && proxy_data[:dest_ip].ipv4?
800
+ else
801
+ raise unless proxy_data[:source_ip].ipv6? && proxy_data[:dest_ip].ipv6?
802
+ end
803
+
804
+ proxy_data[:source_ip] = proxy_data[:source_ip].to_s
805
+ proxy_data[:dest_ip] = proxy_data[:dest_ip].to_s
806
+ rescue StandardError
807
+ raise ServiceUnavailable, "Abort connection for unsupported PROXY parameters!"
808
+ end
809
+ end
810
+
811
+ proxy_data = proxy(session, proxy_data) || proxy_data
812
+ session.proxy = proxy_data
813
+ if proxy_data[:source_ip]
814
+ claim_connection_slot!(proxy_data[:source_ip], enforce_per_ip: true)
815
+ session.remote_ip = proxy_data[:source_ip]
816
+ end
817
+
818
+ # No SMTP reply — PROXY is a preamble, not a command the client awaits.
819
+ ""
820
+ end
821
+
822
+ def process_starttls(session)
823
+ raise CommandNotRecognized unless tls
824
+ raise BadSequence unless session.phase == :ready
825
+ raise BadSequence if session.encrypted?
826
+
827
+ session.phase = :starttls
828
+ reply(220, "Ready to start TLS")
829
+ end
830
+
831
+ def process_auth(session, line)
832
+ raise CommandNotRecognized if @auth == :disabled
833
+ raise BadSequence if session.phase != :ready || session.authenticated?
834
+ raise EncryptionRequired if encryption_required? && !session.encrypted?
835
+
836
+ auth_data = line.sub(/\AAUTH\s/i, "").strip.split(/\s+/)
837
+ mechanism = auth_data[0].to_s.upcase
838
+ raise AuthenticationTypeNotSupported unless auth_mechanism_offered?(session, mechanism)
839
+
840
+ # PLAIN and LOGIN both send the same secret over the required TLS, just in
841
+ # one response vs. across two prompts. Other advertised names need an
842
+ # engine handler before AUTH can succeed (504 until then).
843
+ case mechanism
844
+ when "PLAIN"
845
+ if auth_data.length == 1
846
+ session.phase = :auth_plain
847
+ "334 "
848
+ else
849
+ process_auth_plain(session, auth_data[1])
850
+ end
851
+ when "LOGIN"
852
+ if auth_data.length == 1
853
+ session.phase = :auth_login_user
854
+ AUTH_LOGIN_USERNAME_PROMPT
855
+ else
856
+ process_auth_login_username(session, auth_data[1])
857
+ end
858
+ else
859
+ raise AuthenticationTypeNotSupported
860
+ end
861
+ end
862
+
863
+ def process_rset(session)
864
+ raise BadSequence if auth_value_phase?(session.phase) || session.phase == :data
865
+ raise EncryptionRequired if session.phase != :helo && encryption_required? && !session.encrypted?
866
+
867
+ # Before HELO there is no transaction to clear; still answer 250 (RFC 5321).
868
+ session.reset unless session.phase == :helo
869
+ reply(250, "OK")
870
+ end
871
+
872
+ def process_mail_from(session, line)
873
+ raise BadSequence if session.phase != :ready
874
+ raise EncryptionRequired if encryption_required? && !session.encrypted?
875
+ raise AuthenticationRequired if @auth == :required && !session.authenticated?
876
+
877
+ address = line.sub(/\AMAIL FROM:/i, "").strip
878
+ raise InvalidParameters if address.include?("\0")
879
+ address = apply_mail_from_extensions!(session, address)
880
+
881
+ address = mail_from(session, address) || address
882
+ session.envelope.from = address
883
+ session.phase = :mail
884
+ reply(250, "OK")
885
+ end
886
+
887
+ # BODY is the 8BITMIME parameter (RFC 6152). SMTPUTF8 (RFC 6531) marks an
888
+ # internationalized envelope. SIZE= (RFC 1870) is checked against
889
+ # +max_message_size+ when set. Unknown BODY= values are always refused;
890
+ # BODY=/SMTPUTF8 themselves require the matching extension to be on.
891
+ # Remaining KEY=VALUE ESMTP parameters after known ones are stripped are
892
+ # refused with 555 (RFC 1869 §6.1) — never silently dropped.
893
+ def apply_mail_from_extensions!(session, address)
894
+ if (declared = address[/\sSIZE=(\d+)\b/i, 1])
895
+ declared = Integer(declared)
896
+ session.envelope.declared_size = declared
897
+ if @max_message_size && declared > @max_message_size
898
+ raise MessageTooLarge, "Declared #{declared} bytes, limit is #{@max_message_size}"
899
+ end
900
+ end
901
+
902
+ if address.match?(/\sBODY=7BIT\b/i)
903
+ raise InvalidParameters unless @eight_bit_mime
904
+ session.envelope.encoding_body = "7bit"
905
+ elsif address.match?(/\sBODY=8BITMIME\b/i)
906
+ raise InvalidParameters unless @eight_bit_mime
907
+ session.envelope.encoding_body = "8bitmime"
908
+ elsif address.match?(/\sBODY=\S+/i)
909
+ raise InvalidParameters
910
+ end
911
+
912
+ if address.match?(/\sSMTPUTF8\b/i)
913
+ raise InvalidParameters unless @smtputf8
914
+ session.envelope.encoding_utf8 = "utf8"
915
+ end
916
+
917
+ raise InvalidParameters if !@smtputf8 && !address.ascii_only?
918
+
919
+ address = address
920
+ .gsub(/\sSIZE=\d+\b/i, "")
921
+ .gsub(/\sBODY=(?:7BIT|8BITMIME)\b/i, "")
922
+ .gsub(/\sSMTPUTF8\b/i, "")
923
+ .strip
924
+
925
+ raise ParametersNotRecognized if address.match?(/\s+\S+=\S+/)
926
+ address
927
+ end
928
+
929
+ def process_rcpt_to(session, line)
930
+ raise BadSequence unless %i[ mail rcpt ].include?(session.phase)
931
+ raise EncryptionRequired if encryption_required? && !session.encrypted?
932
+ raise AuthenticationRequired if @auth == :required && !session.authenticated?
933
+ raise TooManyRecipients if session.envelope.to.size >= @max_recipients
934
+
935
+ address = line.sub(/\ARCPT TO:/i, "").strip
936
+ raise InvalidParameters if address.include?("\0")
937
+ raise InvalidParameters if !@smtputf8 && !address.ascii_only?
938
+ # RCPT TO advertises no ESMTP parameters — refuse any KEY=VALUE trail.
939
+ raise ParametersNotRecognized if address.match?(/\s+\S+=\S+/)
940
+
941
+ address = rcpt_to(session, address) || address
942
+ session.envelope.to << address
943
+ session.phase = :rcpt
944
+ reply(250, "OK")
945
+ end
946
+
947
+ def process_data_start(session)
948
+ raise BadSequence if session.phase != :rcpt
949
+ raise EncryptionRequired if encryption_required? && !session.encrypted?
950
+ raise AuthenticationRequired if @auth == :required && !session.authenticated?
951
+
952
+ session.phase = :data
953
+ reply(354, "Enter message, ending with \".\" on a line by itself", enhanced: nil)
954
+ end
955
+
956
+ # Send no SMTP replies until the terminating "." — then one success or error.
957
+ def transfer_data(session, io, io_buffer, session_deadline: nil)
958
+ claim_processing_slot!
959
+ idle_since = [ monotonic_time ]
960
+ reader = DataReader.new(
961
+ max_message_size: @max_message_size,
962
+ max_line_length: MAX_TEXT_LINE_LENGTH,
963
+ on_headers: -> { headers_received(session) }
964
+ ) { read_data_line!(io, io_buffer, idle_since, session_deadline) }
965
+
966
+ reader = PrefixedReader.new(received_header(session), reader) if @add_received
967
+
968
+ error = nil
969
+ begin
970
+ receiving_started(session)
971
+ receive(session, reader)
972
+ rescue ServiceUnavailable
973
+ raise
974
+ rescue Error => e
975
+ error = e
976
+ rescue StandardError => e
977
+ log(session, Logger::ERROR, loggable_error(e), err: e)
978
+ error = LocalError.new
979
+ ensure
980
+ reader.drain
981
+ session.reset
982
+ # RFC 5321 §4.5.3.1.7: after 552 for SIZE, the server may close.
983
+ # Same when drain hit the absolute ceiling with no "." in sight.
984
+ if (error || reader.failure).is_a?(MessageTooLarge) || reader.drain_ceiling_hit?
985
+ session.close_after_reply = true
986
+ end
987
+ end
988
+
989
+ error ||= reader.failure
990
+ if error
991
+ session.exceptions += 1
992
+ log(session, Logger::ERROR, loggable_error(error), err: error)
993
+ error.response
994
+ else
995
+ reply(250, "Requested mail action okay, completed")
996
+ end
997
+ ensure
998
+ release_processing_slot!
999
+ end
1000
+
1001
+ # Next DATA line as dot-unstuffed bytes plus CRLF, or +nil+ at the terminator.
1002
+ # The terminating "." counts only when it arrived as CRLF-terminated
1003
+ # (RFC 5321 / CVE-2023-51764 class SMTP smuggling). A bare-LF "." is body.
1004
+ def read_data_line!(io, io_buffer, idle_since, session_deadline)
1005
+ loop do
1006
+ raise_if_session_expired!(session_deadline)
1007
+
1008
+ lf = io_buffer.index("\n")
1009
+ unless lf
1010
+ raise_if_idle_timeout!(idle_since[0])
1011
+
1012
+ begin
1013
+ io_buffer << io.read_nonblock(IO_BUFFER_CHUNK_SIZE)
1014
+ raise BufferOverrun if @io_buffer_max_size && io_buffer.length > @io_buffer_max_size
1015
+ rescue IO::WaitReadable
1016
+ raise_if_session_expired!(session_deadline)
1017
+ raise_if_idle_timeout!(idle_since[0])
1018
+ IO.select([ io.to_io ], nil, nil, IO_WAIT_TIMEOUT)
1019
+ rescue IO::WaitWritable
1020
+ raise_if_session_expired!(session_deadline)
1021
+ raise_if_idle_timeout!(idle_since[0])
1022
+ IO.select(nil, [ io.to_io ], nil, IO_WAIT_TIMEOUT)
1023
+ end
1024
+
1025
+ raise ServiceUnavailable, "Connection lost during DATA" if io.closed?
1026
+ raise ServiceUnavailable, "Server shutting down" if shutdown?
1027
+ next
1028
+ end
1029
+
1030
+ line = io_buffer.slice!(0, lf + 1)
1031
+ crlf = line.end_with?("\r\n")
1032
+ raise BareNewline if @forbid_bare_newline && !crlf
1033
+
1034
+ strip_line_terminator(line)
1035
+ idle_since[0] = monotonic_time
1036
+
1037
+ # End-of-data is only <CRLF>.<CRLF> — never a bare-LF dot.
1038
+ return nil if line == "." && crlf
1039
+
1040
+ line.slice!(0) if line[0] == "."
1041
+ return "#{line}\r\n"
1042
+ end
1043
+ rescue BufferOverrun
1044
+ raise ServiceUnavailable, "Input buffer overrun"
1045
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED
1046
+ raise ServiceUnavailable, "Connection lost during DATA"
1047
+ end
1048
+
1049
+ def monotonic_time
1050
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1051
+ end
1052
+
1053
+ # Apply +@handshake_timeout+ even when +read_timeout+ is nil. Prefer the
1054
+ # socket's own timeout (interrupts OpenSSL accept); wrap with Timeout as a
1055
+ # fallback for pure-Ruby +tls+ objects (test doubles, sleep-based stubs).
1056
+ def with_handshake_timeout(io)
1057
+ previous = io.respond_to?(:timeout) ? io.timeout : :unset
1058
+ io.timeout = @handshake_timeout if io.respond_to?(:timeout=)
1059
+ Timeout.timeout(@handshake_timeout) { yield }
1060
+ ensure
1061
+ if previous != :unset && io.respond_to?(:timeout=)
1062
+ io.timeout = previous
1063
+ end
1064
+ end
1065
+
1066
+ def raise_if_idle_timeout!(idle_since)
1067
+ return unless @read_timeout
1068
+ raise ServiceUnavailable, "Read timed out" if monotonic_time - idle_since > @read_timeout
1069
+ end
1070
+
1071
+ def raise_if_session_expired!(session_deadline)
1072
+ return unless session_deadline
1073
+ raise ServiceUnavailable, "Session time limit exceeded" if monotonic_time > session_deadline
1074
+ end
1075
+
1076
+ def process_auth_plain(session, encoded_auth_response)
1077
+ auth_values = decode_base64(encoded_auth_response).split("\x00")
1078
+ raise InvalidParameters unless auth_values.length == 3
1079
+
1080
+ authorize(session, *auth_values)
1081
+ ensure
1082
+ session.phase = :ready
1083
+ end
1084
+
1085
+ # LOGIN sends the username and secret on separate base64 lines; hold the
1086
+ # username between turns, then run the same authorize path PLAIN does.
1087
+ def process_auth_login_username(session, encoded_username)
1088
+ session.auth_login_username = decode_base64(encoded_username)
1089
+ session.phase = :auth_login_pass
1090
+ AUTH_LOGIN_PASSWORD_PROMPT
1091
+ rescue Error
1092
+ session.phase = :ready
1093
+ raise
1094
+ end
1095
+
1096
+ def process_auth_login_password(session, encoded_password)
1097
+ authorize(session, "", session.auth_login_username, decode_base64(encoded_password))
1098
+ ensure
1099
+ session.auth_login_username = nil
1100
+ session.phase = :ready
1101
+ end
1102
+
1103
+ # Strictly, so that a mangled credential is refused outright: Base64.decode64
1104
+ # silently discards anything outside the alphabet, which would turn a typo or
1105
+ # a smuggled byte into a different, perfectly plausible credential.
1106
+ def decode_base64(encoded_auth_response)
1107
+ Base64.strict_decode64(encoded_auth_response)
1108
+ rescue ArgumentError
1109
+ raise InvalidParameters
1110
+ end
1111
+
1112
+ def authorize(session, authorization_id, authentication_id, authentication)
1113
+ return_value = authenticate(session,
1114
+ username: authentication_id,
1115
+ password: authentication,
1116
+ authorization_id: authorization_id)
1117
+ authorization_id = return_value if return_value
1118
+
1119
+ session.authorization_id = authorization_id.to_s.empty? ? authentication_id : authorization_id
1120
+ session.authentication_id = authentication_id
1121
+ session.authenticated_at = Time.now.utc
1122
+ reply(235, "OK", enhanced: "2.7.0")
1123
+ end
1124
+
1125
+ def starttls_available?(session)
1126
+ tls && !session.encrypted?
1127
+ end
1128
+
1129
+ # Copy cipher metadata off the SSL socket after STARTTLS. OpenSSL returns
1130
+ # cipher as [name, version, bits, alg_bits]; we keep the name string.
1131
+ def record_tls_details!(session, io)
1132
+ session.tls_version = io.ssl_version if io.respond_to?(:ssl_version)
1133
+ return unless io.respond_to?(:cipher)
1134
+
1135
+ cipher = io.cipher
1136
+ session.tls_cipher = cipher.is_a?(Array) ? cipher[0] : cipher
1137
+ end
1138
+
1139
+ def auth_available?(session)
1140
+ return false if @auth == :disabled
1141
+ # When STARTTLS is required, do not advertise AUTH until the session is encrypted.
1142
+ return false if encryption_required? && !session.encrypted?
1143
+ return false if auth_mechanisms(session).empty?
1144
+
1145
+ true
1146
+ end
1147
+
1148
+ def auth_ehlo_line(session)
1149
+ return unless auth_available?(session)
1150
+
1151
+ mechanisms = auth_mechanisms(session).map { |name| name.to_s.upcase }.uniq
1152
+ return if mechanisms.empty?
1153
+
1154
+ "250-AUTH #{mechanisms.join(" ")}"
1155
+ end
1156
+
1157
+ def auth_mechanism_offered?(session, mechanism)
1158
+ auth_mechanisms(session).any? { |name| name.to_s.casecmp?(mechanism) }
1159
+ end
1160
+
1161
+ def encryption_required?
1162
+ @starttls == :required
1163
+ end
1164
+
1165
+ def auth_line_length?(session, line)
1166
+ auth_value_phase?(session.phase) || line.match?(/\AAUTH\s/i)
1167
+ end
1168
+
1169
+ # After the SMTP error reply, drop the session when AUTH or general error
1170
+ # budgets are exhausted. +nil+ disables either ceiling.
1171
+ def raise_if_exception_caps!(session, error)
1172
+ if error.is_a?(AuthenticationFailed) && @max_auth_failures &&
1173
+ session.auth_failures >= @max_auth_failures
1174
+ raise ServiceUnavailable, "Too many authentication failures"
1175
+ end
1176
+ if @max_exceptions && session.exceptions >= @max_exceptions
1177
+ raise ServiceUnavailable, "Too many protocol errors"
1178
+ end
1179
+ end
1180
+
1181
+ # SO_LINGER with timeout 0 yields RST on close — used on forced stop so
1182
+ # clients do not linger on a half-closed FIN.
1183
+ def abort_socket!(io)
1184
+ return unless io && !io.closed?
1185
+
1186
+ io.setsockopt(Socket::SOL_SOCKET, Socket::SO_LINGER, [ 1, 0 ].pack("ii"))
1187
+ io.close
1188
+ rescue StandardError
1189
+ # already gone
1190
+ end
1191
+
1192
+ # DATA always accepts following body lines in the buffer. PROXY may be
1193
+ # followed immediately by HELO/EHLO before the extension finishes negotiating
1194
+ # the real client IP (typical load-balancer / PROXY clients).
1195
+ def pipelining_allowed?(session)
1196
+ @pipelining || (proxy_enabled? && session.phase == :helo)
1197
+ end
1198
+ end
1199
+ end