mailcatcher-ng 1.2.0 → 1.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6ce8da5001fe3dbf561a375431bda1fb4a1011dd09286307c05aa4ce0c206dd
4
- data.tar.gz: 11727e123a3a6395ebf5ecf1eb93fc7258889f203260e2d6fbf9f3c3f67aaa43
3
+ metadata.gz: 71c31256fbd4bdc76749770fe321db1351839f23fd01d1ef2ab6f21382da774b
4
+ data.tar.gz: 7f77c22a8dd2e3b7cea54c6ec415af7f408ad39588e48772cffaec625dd8fd15
5
5
  SHA512:
6
- metadata.gz: dfd03e9c5c8e77348c9853004afdd37d23d3c7c1c7ee6dad7e38a38943d7e8e3943be25379835429dcd027ecccf948ddde05b71227f3382fa78ac13fdeebf2fd
7
- data.tar.gz: dd822cf0ca1c50a307bfb8001e20187690b8ce99a8b1ef0ef043c094b51682e90e103ece5bf4d1d278740ef020cecb77d4632ab165d290bb2a2169634dc4cafa
6
+ metadata.gz: 3e47f2938af045fd8da6b1e7eacb1a0552a5076bd5415c1079db271335271fc1299c3c47e3dfc78b9eaf1383699f339c1b7f674ff3169bdf65f270666c819255
7
+ data.tar.gz: c49f21a836aae6a35f84568997ddc29f4c619b3cebead68421a882b4ab3ff5ae4685f39e7a69a0473840d83831f88b55b01b4cbe0af5399981f04663319e65f5
@@ -36,6 +36,27 @@ module MailCatcher::Mail extend self
36
36
  FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
37
37
  )
38
38
  SQL
39
+ db.execute(<<-SQL)
40
+ CREATE TABLE smtp_transcript (
41
+ id INTEGER PRIMARY KEY ASC,
42
+ message_id INTEGER,
43
+ session_id TEXT NOT NULL,
44
+ client_ip TEXT,
45
+ client_port INTEGER,
46
+ server_ip TEXT,
47
+ server_port INTEGER,
48
+ tls_enabled INTEGER DEFAULT 0,
49
+ tls_protocol TEXT,
50
+ tls_cipher TEXT,
51
+ connection_started_at DATETIME,
52
+ connection_ended_at DATETIME,
53
+ entries TEXT NOT NULL,
54
+ created_at DATETIME DEFAULT CURRENT_DATETIME,
55
+ FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
56
+ )
57
+ SQL
58
+ db.execute("CREATE INDEX idx_smtp_transcript_message_id ON smtp_transcript(message_id)")
59
+ db.execute("CREATE INDEX idx_smtp_transcript_session_id ON smtp_transcript(session_id)")
39
60
  db.execute("PRAGMA foreign_keys = ON")
40
61
  end
41
62
  end
@@ -60,6 +81,8 @@ module MailCatcher::Mail extend self
60
81
  message = MailCatcher::Mail.message message_id
61
82
  MailCatcher::Bus.push(type: "add", message: message)
62
83
  end
84
+
85
+ message_id
63
86
  end
64
87
 
65
88
  def add_message_part(*args)
@@ -387,6 +410,66 @@ module MailCatcher::Mail extend self
387
410
  encryption_data
388
411
  end
389
412
 
413
+ def add_smtp_transcript(params)
414
+ @add_smtp_transcript_query ||= db.prepare(<<-SQL)
415
+ INSERT INTO smtp_transcript (
416
+ message_id, session_id, client_ip, client_port,
417
+ server_ip, server_port, tls_enabled, tls_protocol,
418
+ tls_cipher, connection_started_at, connection_ended_at,
419
+ entries, created_at
420
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
421
+ SQL
422
+
423
+ @add_smtp_transcript_query.execute(
424
+ params[:message_id],
425
+ params[:session_id],
426
+ params[:client_ip],
427
+ params[:client_port],
428
+ params[:server_ip],
429
+ params[:server_port],
430
+ params[:tls_enabled],
431
+ params[:tls_protocol],
432
+ params[:tls_cipher],
433
+ params[:connection_started_at]&.utc&.iso8601,
434
+ params[:connection_ended_at]&.utc&.iso8601,
435
+ JSON.generate(params[:entries])
436
+ )
437
+ end
438
+
439
+ def message_transcript(message_id)
440
+ @message_transcript_query ||= db.prepare(<<-SQL)
441
+ SELECT id, session_id, client_ip, client_port,
442
+ server_ip, server_port, tls_enabled, tls_protocol,
443
+ tls_cipher, connection_started_at, connection_ended_at,
444
+ entries, created_at
445
+ FROM smtp_transcript
446
+ WHERE message_id = ?
447
+ ORDER BY created_at DESC
448
+ LIMIT 1
449
+ SQL
450
+
451
+ row = @message_transcript_query.execute(message_id).next
452
+ return nil unless row
453
+
454
+ result = Hash[@message_transcript_query.columns.zip(row)]
455
+ result['entries'] = JSON.parse(result['entries']) if result['entries']
456
+ result['tls_enabled'] = result['tls_enabled'] == 1
457
+ result
458
+ end
459
+
460
+ def all_transcripts
461
+ @all_transcripts_query ||= db.prepare(<<-SQL)
462
+ SELECT id, message_id, session_id, client_ip,
463
+ connection_started_at, tls_enabled
464
+ FROM smtp_transcript
465
+ ORDER BY created_at DESC
466
+ SQL
467
+
468
+ @all_transcripts_query.execute.map do |row|
469
+ Hash[@all_transcripts_query.columns.zip(row)]
470
+ end
471
+ end
472
+
390
473
  private
391
474
 
392
475
  def parse_authentication_results(auth_header)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "eventmachine"
4
+ require "securerandom"
5
+ require "socket"
4
6
 
5
7
  require "mail_catcher/mail"
6
8
 
@@ -11,16 +13,111 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
11
13
  @@active_connections
12
14
  end
13
15
 
16
+ def initialize(*args)
17
+ @transcript_entries = []
18
+ @session_id = SecureRandom.uuid
19
+ @connection_started_at = Time.now
20
+ @data_started = false
21
+ super
22
+ end
23
+
14
24
  def post_init
15
25
  @@active_connections += 1
26
+
27
+ # Get connection details
28
+ begin
29
+ peer_sockaddr = get_peername
30
+ if peer_sockaddr
31
+ port, client_ip = Socket.unpack_sockaddr_in(peer_sockaddr)
32
+ @client_ip = client_ip
33
+ @client_port = port
34
+ end
35
+ rescue => e
36
+ $stderr.puts "Error getting peer info: #{e.message}"
37
+ end
38
+
39
+ begin
40
+ local_sockaddr = get_sockname
41
+ if local_sockaddr
42
+ port, server_ip = Socket.unpack_sockaddr_in(local_sockaddr)
43
+ @server_ip = server_ip
44
+ @server_port = port
45
+ end
46
+ rescue => e
47
+ $stderr.puts "Error getting local info: #{e.message}"
48
+ end
49
+
50
+ log_transcript('connection', 'server', "Connection established from #{@client_ip}:#{@client_port}")
51
+
16
52
  super
17
53
  end
18
54
 
19
55
  def unbind
20
56
  @@active_connections -= 1
57
+
58
+ @connection_ended_at = Time.now
59
+ log_transcript('connection', 'server', "Connection closed")
60
+
61
+ # Save transcript even if no message was completed
62
+ save_transcript(nil) if @transcript_entries.any?
63
+
21
64
  super
22
65
  end
23
66
 
67
+ def log_transcript(type, direction, message)
68
+ @transcript_entries << {
69
+ timestamp: Time.now.utc.iso8601(3),
70
+ type: type,
71
+ direction: direction,
72
+ message: message
73
+ }
74
+ rescue => e
75
+ $stderr.puts "Error logging transcript: #{e.message}"
76
+ end
77
+
78
+ def save_transcript(message_id)
79
+ return if @transcript_entries.empty?
80
+
81
+ begin
82
+ tls_enabled = @tls_started ? 1 : 0
83
+ tls_protocol = nil
84
+ tls_cipher = nil
85
+
86
+ if @tls_started
87
+ begin
88
+ tls_protocol = get_cipher_protocol
89
+ rescue
90
+ # TLS info not available
91
+ end
92
+ begin
93
+ tls_cipher = get_cipher_name
94
+ rescue
95
+ # TLS info not available
96
+ end
97
+ end
98
+
99
+ MailCatcher::Mail.add_smtp_transcript(
100
+ message_id: message_id,
101
+ session_id: @session_id,
102
+ client_ip: @client_ip,
103
+ client_port: @client_port,
104
+ server_ip: @server_ip,
105
+ server_port: @server_port,
106
+ tls_enabled: tls_enabled,
107
+ tls_protocol: tls_protocol,
108
+ tls_cipher: tls_cipher,
109
+ connection_started_at: @connection_started_at,
110
+ connection_ended_at: @connection_ended_at || Time.now,
111
+ entries: @transcript_entries
112
+ )
113
+
114
+ @transcript_entries = []
115
+ rescue => e
116
+ $stderr.puts "Error saving SMTP transcript: #{e.message}"
117
+ $stderr.puts e.backtrace.join("\n")
118
+ end
119
+ end
120
+
24
121
  # We override EM's mail from processing to allow multiple mail-from commands
25
122
  # per [RFC 2821](https://tools.ietf.org/html/rfc2821#section-4.1.1.2)
26
123
  def process_mail_from sender
@@ -38,7 +135,11 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
38
135
  end
39
136
 
40
137
  def receive_reset
138
+ log_transcript('command', 'client', 'RSET')
139
+ log_transcript('response', 'server', '250 OK')
140
+
41
141
  @current_message = nil
142
+ @data_started = false
42
143
 
43
144
  true
44
145
  end
@@ -48,33 +149,46 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
48
149
  # SIZE: RFC 1870 - Message size extension
49
150
  # 8BITMIME: RFC 6152 - 8bit MIME transport
50
151
  # SMTPUTF8: RFC 6531 - UTF-8 support in SMTP
51
- capabilities = super.to_a
52
- capabilities << "8BITMIME"
53
- capabilities << "SMTPUTF8"
54
- capabilities
152
+ ["8BITMIME", "SMTPUTF8"]
55
153
  end
56
154
 
57
155
  def receive_sender(sender)
156
+ # Log the full MAIL FROM command with ESMTP parameters
157
+ log_transcript('command', 'client', "MAIL FROM:<#{sender}>")
158
+
58
159
  # EventMachine SMTP advertises size extensions [https://tools.ietf.org/html/rfc1870]
59
160
  # and other SMTP parameters via the MAIL FROM command
60
161
  # Strip potential " SIZE=..." and "BODY=..." suffixes from senders
61
- sender = sender.gsub(/ (?:SIZE|BODY)=\S+/i, "")
162
+ sender_cleaned = sender.gsub(/ (?:SIZE|BODY)=\S+/i, "")
163
+
164
+ log_transcript('response', 'server', '250 OK')
62
165
 
63
- current_message[:sender] = sender
166
+ current_message[:sender] = sender_cleaned
64
167
  # Store the original sender line to track if 8BIT was specified
65
- current_message[:sender_line] = sender
168
+ current_message[:sender_line] = sender_cleaned
66
169
 
67
170
  true
68
171
  end
69
172
 
70
173
  def receive_recipient(recipient)
174
+ log_transcript('command', 'client', "RCPT TO:<#{recipient}>")
175
+
71
176
  current_message[:recipients] ||= []
72
177
  current_message[:recipients] << recipient
73
178
 
179
+ log_transcript('response', 'server', '250 OK')
180
+
74
181
  true
75
182
  end
76
183
 
77
184
  def receive_data_chunk(lines)
185
+ # Log DATA command on first chunk only
186
+ if !@data_started
187
+ @data_started = true
188
+ log_transcript('command', 'client', 'DATA')
189
+ log_transcript('response', 'server', '354 Start mail input; end with <CRLF>.<CRLF>')
190
+ end
191
+
78
192
  current_message[:source] ||= +""
79
193
 
80
194
  lines.each do |line|
@@ -85,28 +199,124 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
85
199
  end
86
200
 
87
201
  def receive_message
88
- MailCatcher::Mail.add_message current_message
202
+ message_size = current_message[:source].length
203
+ log_transcript('data', 'client', "Message complete (#{message_size} bytes)")
204
+ log_transcript('response', 'server', '250 OK: Message accepted')
205
+
206
+ begin
207
+ message_id = MailCatcher::Mail.add_message current_message
208
+ rescue => e
209
+ $stderr.puts "Error in add_message: #{e.message}"
210
+ $stderr.puts e.backtrace.join("\n")
211
+ message_id = nil
212
+ end
213
+
89
214
  MailCatcher::Mail.delete_older_messages!
90
- puts "==> SMTP: Received message from '#{current_message[:sender]}' (#{current_message[:source].length} bytes)"
215
+
216
+ # Save transcript linked to message
217
+ save_transcript(message_id)
218
+
91
219
  true
92
220
  rescue => exception
221
+ log_transcript('error', 'server', "Exception: #{exception.class} - #{exception.message}")
93
222
  MailCatcher.log_exception("Error receiving message", @current_message, exception)
223
+
224
+ # Save transcript even on error
225
+ save_transcript(nil)
226
+
94
227
  false
95
228
  ensure
96
229
  @current_message = nil
230
+ @data_started = false
231
+ end
232
+
233
+ # Override to log EHLO/HELO command
234
+ def receive_ehlo_domain(domain)
235
+ log_transcript('command', 'client', "EHLO #{domain}")
236
+
237
+ # Call parent to handle the EHLO response
238
+ result = super
239
+
240
+ # Log the capabilities after they're sent
241
+ capabilities = get_server_capabilities
242
+ capabilities_str = capabilities.map { |cap| "250-#{cap}" }.join("\r\n")
243
+ # Replace first 250- with 250 followed by a space
244
+ if capabilities_str.start_with?("250-")
245
+ capabilities_str = "250 " + capabilities_str[4..-1]
246
+ end
247
+ log_transcript('response', 'server', capabilities_str)
248
+
249
+ result
250
+ end
251
+
252
+ # Override to log STARTTLS command and TLS negotiation
253
+ def process_starttls
254
+ log_transcript('tls', 'client', 'STARTTLS')
255
+ result = super
256
+
257
+ # Log TLS info after negotiation
258
+ if @tls_started
259
+ begin
260
+ protocol = get_cipher_protocol
261
+ cipher = get_cipher_name
262
+ log_transcript('tls', 'server', "TLS negotiation completed (#{protocol}, #{cipher})")
263
+ rescue
264
+ log_transcript('tls', 'server', "TLS negotiation completed")
265
+ end
266
+ end
267
+
268
+ result
269
+ end
270
+
271
+ # Override to log AUTH attempts (without logging credentials)
272
+ def receive_plain_auth(user, password)
273
+ log_transcript('command', 'client', "AUTH PLAIN [credentials hidden]")
274
+ result = super
275
+
276
+ if result
277
+ log_transcript('response', 'server', '235 2.7.0 Authentication successful')
278
+ else
279
+ log_transcript('response', 'server', '535 5.7.8 Authentication credentials invalid')
280
+ end
281
+
282
+ result
283
+ end
284
+
285
+ # Override to log unknown/invalid commands
286
+ def process_unknown(command, data)
287
+ log_transcript('command', 'client', "#{command} #{data}".strip)
288
+ result = super
289
+
290
+ log_transcript('response', 'server', '500 5.5.1 Command not recognized')
291
+
292
+ result
97
293
  end
98
294
  end
99
295
 
100
296
  # Direct TLS (SMTPS) handler that starts TLS immediately on connection
101
297
  class MailCatcher::SmtpTls < MailCatcher::Smtp
102
298
  def post_init
103
- # Increment connection count
299
+ # Increment connection count and set up connection info
104
300
  super
105
301
 
302
+ # Log immediate TLS (SMTPS on port 1465)
303
+ log_transcript('tls', 'server', 'Immediate TLS enabled (SMTPS)')
304
+
106
305
  # Start TLS immediately on connection for SMTPS (port 1465 behavior)
107
306
  # The @@parms hash with starttls_options is already set by configure_smtp_ssl!
108
307
  if defined?(@@parms) && @@parms[:starttls_options]
109
308
  start_tls(@@parms[:starttls_options])
309
+
310
+ # Log TLS details after negotiation
311
+ if @tls_started
312
+ begin
313
+ protocol = get_cipher_protocol
314
+ cipher = get_cipher_name
315
+ log_transcript('tls', 'server', "TLS negotiation completed (#{protocol}, #{cipher})")
316
+ rescue
317
+ # TLS info not available yet
318
+ end
319
+ end
110
320
  end
111
321
  end
112
322
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MailCatcher
4
- VERSION = '1.2.0'
4
+ VERSION = '1.3.1'
5
5
  end
@@ -161,7 +161,8 @@ module MailCatcher
161
161
  "formats" => [
162
162
  "source",
163
163
  ("html" if Mail.message_has_html? id),
164
- ("plain" if Mail.message_has_plain? id)
164
+ ("plain" if Mail.message_has_plain? id),
165
+ ("transcript" if Mail.message_transcript(id))
165
166
  ].compact,
166
167
  "attachments" => Mail.message_attachments(id),
167
168
  "bimi_location" => Mail.message_bimi_location(id),
@@ -169,7 +170,7 @@ module MailCatcher
169
170
  "authentication_results" => Mail.message_authentication_results(id),
170
171
  "encryption_data" => Mail.message_encryption_data(id),
171
172
  "from_header" => Mail.message_from(id),
172
- "to_header" => Mail.message_to(id),
173
+ "to_header" => Mail.message_to(id)
173
174
  }))
174
175
  else
175
176
  not_found
@@ -222,6 +223,26 @@ module MailCatcher
222
223
  end
223
224
  end
224
225
 
226
+ get "/messages/:id/transcript.json" do
227
+ id = params[:id].to_i
228
+ if transcript = Mail.message_transcript(id)
229
+ content_type :json
230
+ JSON.generate(transcript)
231
+ else
232
+ not_found
233
+ end
234
+ end
235
+
236
+ get "/messages/:id.transcript" do
237
+ id = params[:id].to_i
238
+ if transcript = Mail.message_transcript(id)
239
+ content_type :html, charset: "utf-8"
240
+ erb :transcript, locals: { transcript: transcript }
241
+ else
242
+ not_found
243
+ end
244
+ end
245
+
225
246
  get "/messages/:id/parts/:cid" do
226
247
  id = params[:id].to_i
227
248
  if part = Mail.message_part_cid(id, params[:cid])