Class: MailCatcher::Smtp

Inherits:
EventMachine::Protocols::SmtpServer
  • Object
show all
Defined in:
lib/mail_catcher/smtp.rb

Direct Known Subclasses

SmtpTls

Constant Summary collapse

@@active_connections =
0

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(*args) ⇒ Smtp

Returns a new instance of Smtp.



16
17
18
19
20
21
22
23
# File 'lib/mail_catcher/smtp.rb', line 16

def initialize(*args)
  @transcript_entries = []
  @session_id = SecureRandom.uuid
  @connection_started_at = Time.now
  @data_started = false
  @last_message_id = nil
  super
end

Class Method Details

.connection_countObject



12
13
14
# File 'lib/mail_catcher/smtp.rb', line 12

def self.connection_count
  @@active_connections
end

Instance Method Details

#current_messageObject



135
136
137
# File 'lib/mail_catcher/smtp.rb', line 135

def current_message
  @current_message ||= {}
end

#get_server_capabilitiesObject



149
150
151
152
153
154
155
# File 'lib/mail_catcher/smtp.rb', line 149

def get_server_capabilities
  # Advertise SMTP capabilities per RFC standards
  # SIZE: RFC 1870 - Message size extension
  # 8BITMIME: RFC 6152 - 8bit MIME transport
  # SMTPUTF8: RFC 6531 - UTF-8 support in SMTP
  ["8BITMIME", "SMTPUTF8"]
end

#log_transcript(type, direction, message) ⇒ Object



69
70
71
72
73
74
75
76
77
78
# File 'lib/mail_catcher/smtp.rb', line 69

def log_transcript(type, direction, message)
  @transcript_entries << {
    timestamp: Time.now.utc.iso8601(3),
    type: type,
    direction: direction,
    message: message
  }
rescue => e
  $stderr.puts "Error logging transcript: #{e.message}"
end

#post_initObject



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/mail_catcher/smtp.rb', line 25

def post_init
  @@active_connections += 1

  # Get connection details
  begin
    peer_sockaddr = get_peername
    if peer_sockaddr
      port, client_ip = Socket.unpack_sockaddr_in(peer_sockaddr)
      @client_ip = client_ip
      @client_port = port
    end
  rescue => e
    $stderr.puts "Error getting peer info: #{e.message}"
  end

  begin
    local_sockaddr = get_sockname
    if local_sockaddr
      port, server_ip = Socket.unpack_sockaddr_in(local_sockaddr)
      @server_ip = server_ip
      @server_port = port
    end
  rescue => e
    $stderr.puts "Error getting local info: #{e.message}"
  end

  log_transcript('connection', 'server', "Connection established from #{@client_ip}:#{@client_port}")

  super
end

#process_mail_from(sender) ⇒ Object

We override EM’s mail from processing to allow multiple mail-from commands per [RFC 2821](tools.ietf.org/html/rfc2821#section-4.1.1.2)



125
126
127
128
129
130
131
132
133
# File 'lib/mail_catcher/smtp.rb', line 125

def process_mail_from sender
  if @state.include? :mail_from
    @state -= [:mail_from, :rcpt, :data]

    receive_reset
  end

  super
end

#process_starttlsObject

Override to log STARTTLS command and TLS negotiation



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/mail_catcher/smtp.rb', line 255

def process_starttls
  log_transcript('tls', 'client', 'STARTTLS')
  result = super

  # Log TLS info after negotiation
  if @tls_started
    begin
      protocol = get_cipher_protocol
      cipher = get_cipher_name
      log_transcript('tls', 'server', "TLS negotiation completed (#{protocol}, #{cipher})")
    rescue
      log_transcript('tls', 'server', "TLS negotiation completed")
    end
  end

  result
end

#process_unknown(command, data) ⇒ Object

Override to log unknown/invalid commands



288
289
290
291
292
293
294
295
# File 'lib/mail_catcher/smtp.rb', line 288

def process_unknown(command, data)
  log_transcript('command', 'client', "#{command} #{data}".strip)
  result = super

  log_transcript('response', 'server', '500 5.5.1 Command not recognized')

  result
end

#receive_data_chunk(lines) ⇒ Object



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/mail_catcher/smtp.rb', line 186

def receive_data_chunk(lines)
  # Log DATA command on first chunk only
  if !@data_started
    @data_started = true
    log_transcript('command', 'client', 'DATA')
    log_transcript('response', 'server', '354 Start mail input; end with <CRLF>.<CRLF>')
  end

  current_message[:source] ||= +""

  lines.each do |line|
    current_message[:source] << line << "\r\n"
  end

  true
end

#receive_ehlo_domain(domain) ⇒ Object

Override to log EHLO/HELO command



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/mail_catcher/smtp.rb', line 236

def receive_ehlo_domain(domain)
  log_transcript('command', 'client', "EHLO #{domain}")

  # Call parent to handle the EHLO response
  result = super

  # Log the capabilities after they're sent
  capabilities = get_server_capabilities
  capabilities_str = capabilities.map { |cap| "250-#{cap}" }.join("\r\n")
  # Replace first 250- with 250 followed by a space
  if capabilities_str.start_with?("250-")
    capabilities_str = "250 " + capabilities_str[4..-1]
  end
  log_transcript('response', 'server', capabilities_str)

  result
end

#receive_messageObject



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/mail_catcher/smtp.rb', line 203

def receive_message
  message_size = current_message[:source].length
  log_transcript('data', 'client', "Message complete (#{message_size} bytes)")
  log_transcript('response', 'server', '250 OK: Message accepted')

  begin
    message_id = MailCatcher::Mail.add_message current_message
    @last_message_id = message_id
  rescue => e
    $stderr.puts "Error in add_message: #{e.message}"
    $stderr.puts e.backtrace.join("\n")
    message_id = nil
  end

  MailCatcher::Mail.delete_older_messages!

  # Don't save transcript here - save it when connection closes (in unbind)
  # This ensures "Connection closed" entry is included

  true
rescue => exception
  log_transcript('error', 'server', "Exception: #{exception.class} - #{exception.message}")
  MailCatcher.log_exception("Error receiving message", @current_message, exception)

  # Don't save transcript here - save it when connection closes (in unbind)

  false
ensure
  @current_message = nil
  @data_started = false
end

#receive_plain_auth(user, password) ⇒ Object

Override to log AUTH attempts (without logging credentials)



274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/mail_catcher/smtp.rb', line 274

def receive_plain_auth(user, password)
  log_transcript('command', 'client', "AUTH PLAIN [credentials hidden]")
  result = super

  if result
    log_transcript('response', 'server', '235 2.7.0 Authentication successful')
  else
    log_transcript('response', 'server', '535 5.7.8 Authentication credentials invalid')
  end

  result
end

#receive_recipient(recipient) ⇒ Object



175
176
177
178
179
180
181
182
183
184
# File 'lib/mail_catcher/smtp.rb', line 175

def receive_recipient(recipient)
  log_transcript('command', 'client', "RCPT TO:<#{recipient}>")

  current_message[:recipients] ||= []
  current_message[:recipients] << recipient

  log_transcript('response', 'server', '250 OK')

  true
end

#receive_resetObject



139
140
141
142
143
144
145
146
147
# File 'lib/mail_catcher/smtp.rb', line 139

def receive_reset
  log_transcript('command', 'client', 'RSET')
  log_transcript('response', 'server', '250 OK')

  @current_message = nil
  @data_started = false

  true
end

#receive_sender(sender) ⇒ Object



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/mail_catcher/smtp.rb', line 157

def receive_sender(sender)
  # Log the full MAIL FROM command with ESMTP parameters
  log_transcript('command', 'client', "MAIL FROM:<#{sender}>")

  # EventMachine SMTP advertises size extensions [https://tools.ietf.org/html/rfc1870]
  # and other SMTP parameters via the MAIL FROM command
  # Strip potential " SIZE=..." and "BODY=..." suffixes from senders
  sender_cleaned = sender.gsub(/ (?:SIZE|BODY)=\S+/i, "")

  log_transcript('response', 'server', '250 OK')

  current_message[:sender] = sender_cleaned
  # Store the original sender line to track if 8BIT was specified
  current_message[:sender_line] = sender_cleaned

  true
end

#save_transcript(message_id) ⇒ Object



80
81
82
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
# File 'lib/mail_catcher/smtp.rb', line 80

def save_transcript(message_id)
  return if @transcript_entries.empty?

  begin
    tls_enabled = @tls_started ? 1 : 0
    tls_protocol = nil
    tls_cipher = nil

    if @tls_started
      begin
        tls_protocol = get_cipher_protocol
      rescue
        # TLS info not available
      end
      begin
        tls_cipher = get_cipher_name
      rescue
        # TLS info not available
      end
    end

    MailCatcher::Mail.add_smtp_transcript(
      message_id: message_id,
      session_id: @session_id,
      client_ip: @client_ip,
      client_port: @client_port,
      server_ip: @server_ip,
      server_port: @server_port,
      tls_enabled: tls_enabled,
      tls_protocol: tls_protocol,
      tls_cipher: tls_cipher,
      connection_started_at: @connection_started_at,
      connection_ended_at: @connection_ended_at || Time.now,
      entries: @transcript_entries
    )

    @transcript_entries = []
  rescue => e
    $stderr.puts "Error saving SMTP transcript: #{e.message}"
    $stderr.puts e.backtrace.join("\n")
  end
end

#unbindObject



56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/mail_catcher/smtp.rb', line 56

def unbind
  @@active_connections -= 1

  @connection_ended_at = Time.now
  log_transcript('connection', 'server', "Connection closed")

  # Save transcript with the last message if available, otherwise without message_id
  # This ensures "Connection closed" is included in the transcript
  save_transcript(@last_message_id) if @transcript_entries.any?

  super
end