mailcatcher-ng 1.3.1 → 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71c31256fbd4bdc76749770fe321db1351839f23fd01d1ef2ab6f21382da774b
4
- data.tar.gz: 7f77c22a8dd2e3b7cea54c6ec415af7f408ad39588e48772cffaec625dd8fd15
3
+ metadata.gz: 6539793c4e31686b97ba0502cc4c48c47c49ace16230fea4cf2f2cb8d06704c7
4
+ data.tar.gz: d74ed846c8205f2b37b9b433b618424929d12515a928d23b4a481fe03a5993d1
5
5
  SHA512:
6
- metadata.gz: 3e47f2938af045fd8da6b1e7eacb1a0552a5076bd5415c1079db271335271fc1299c3c47e3dfc78b9eaf1383699f339c1b7f674ff3169bdf65f270666c819255
7
- data.tar.gz: c49f21a836aae6a35f84568997ddc29f4c619b3cebead68421a882b4ab3ff5ae4685f39e7a69a0473840d83831f88b55b01b4cbe0af5399981f04663319e65f5
6
+ metadata.gz: fd19f4d57d9710adc6c60a21aeeefbfb2623e57d6725463f5bd01b3ce5e5fcb93360d1543301bc9655b64fc814994d036e3c8cf8de3940643f69ccf1f4063297
7
+ data.tar.gz: 7aed22f4e9a54ca7e239f7401d95931cfce90f309d2beae5d28049aa939b4903bb2d256aac8ff694c3740bf62835d6bf2c5f90d8c9b53fd3f855a2b282e6860e
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "eventmachine"
4
+ require "fileutils"
4
5
  require "json"
5
6
  require "mail"
6
7
  require "sqlite3"
@@ -8,9 +9,10 @@ require "sqlite3"
8
9
  module MailCatcher::Mail extend self
9
10
  def db
10
11
  @__db ||= begin
11
- SQLite3::Database.new(":memory:", :type_translation => true).tap do |db|
12
+ db_path = determine_db_path
13
+ SQLite3::Database.new(db_path, :type_translation => true).tap do |db|
12
14
  db.execute(<<-SQL)
13
- CREATE TABLE message (
15
+ CREATE TABLE IF NOT EXISTS message (
14
16
  id INTEGER PRIMARY KEY ASC,
15
17
  sender TEXT,
16
18
  recipients TEXT,
@@ -22,7 +24,7 @@ module MailCatcher::Mail extend self
22
24
  )
23
25
  SQL
24
26
  db.execute(<<-SQL)
25
- CREATE TABLE message_part (
27
+ CREATE TABLE IF NOT EXISTS message_part (
26
28
  id INTEGER PRIMARY KEY ASC,
27
29
  message_id INTEGER NOT NULL,
28
30
  cid TEXT,
@@ -37,7 +39,7 @@ module MailCatcher::Mail extend self
37
39
  )
38
40
  SQL
39
41
  db.execute(<<-SQL)
40
- CREATE TABLE smtp_transcript (
42
+ CREATE TABLE IF NOT EXISTS smtp_transcript (
41
43
  id INTEGER PRIMARY KEY ASC,
42
44
  message_id INTEGER,
43
45
  session_id TEXT NOT NULL,
@@ -55,13 +57,25 @@ module MailCatcher::Mail extend self
55
57
  FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
56
58
  )
57
59
  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)")
60
+ db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_message_id ON smtp_transcript(message_id)")
61
+ db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_session_id ON smtp_transcript(session_id)")
60
62
  db.execute("PRAGMA foreign_keys = ON")
61
63
  end
62
64
  end
63
65
  end
64
66
 
67
+ def determine_db_path
68
+ if MailCatcher.options && MailCatcher.options[:persistence]
69
+ # Use a persistent SQLite file in the user's home directory
70
+ db_dir = File.expand_path('~/.mailcatcher')
71
+ FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
72
+ File.join(db_dir, 'mailcatcher.db')
73
+ else
74
+ # Use in-memory database
75
+ ':memory:'
76
+ end
77
+ end
78
+
65
79
  def add_message(message)
66
80
  @add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")
67
81
 
@@ -311,7 +325,10 @@ module MailCatcher::Mail extend self
311
325
 
312
326
  def delete!
313
327
  @delete_all_messages_query ||= db.prepare "DELETE FROM message"
328
+ @delete_all_transcripts_query ||= db.prepare "DELETE FROM smtp_transcript"
329
+
314
330
  @delete_all_messages_query.execute
331
+ @delete_all_transcripts_query.execute
315
332
 
316
333
  EventMachine.next_tick do
317
334
  MailCatcher::Bus.push(type: "clear")
@@ -438,7 +455,7 @@ module MailCatcher::Mail extend self
438
455
 
439
456
  def message_transcript(message_id)
440
457
  @message_transcript_query ||= db.prepare(<<-SQL)
441
- SELECT id, session_id, client_ip, client_port,
458
+ SELECT id, message_id, session_id, client_ip, client_port,
442
459
  server_ip, server_port, tls_enabled, tls_protocol,
443
460
  tls_cipher, connection_started_at, connection_ended_at,
444
461
  entries, created_at
@@ -470,6 +487,24 @@ module MailCatcher::Mail extend self
470
487
  end
471
488
  end
472
489
 
490
+ def all_transcript_entries
491
+ @all_transcript_entries_query ||= db.prepare(<<-SQL)
492
+ SELECT id, message_id, session_id, client_ip, client_port,
493
+ server_ip, server_port, tls_enabled, tls_protocol,
494
+ tls_cipher, connection_started_at, connection_ended_at,
495
+ entries, created_at
496
+ FROM smtp_transcript
497
+ ORDER BY created_at DESC
498
+ SQL
499
+
500
+ @all_transcript_entries_query.execute.map do |row|
501
+ result = Hash[@all_transcript_entries_query.columns.zip(row)]
502
+ result['entries'] = JSON.parse(result['entries']) if result['entries']
503
+ result['tls_enabled'] = result['tls_enabled'] == 1
504
+ result
505
+ end
506
+ end
507
+
473
508
  private
474
509
 
475
510
  def parse_authentication_results(auth_header)
@@ -18,6 +18,7 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
18
18
  @session_id = SecureRandom.uuid
19
19
  @connection_started_at = Time.now
20
20
  @data_started = false
21
+ @last_message_id = nil
21
22
  super
22
23
  end
23
24
 
@@ -58,8 +59,9 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
58
59
  @connection_ended_at = Time.now
59
60
  log_transcript('connection', 'server', "Connection closed")
60
61
 
61
- # Save transcript even if no message was completed
62
- save_transcript(nil) if @transcript_entries.any?
62
+ # Save transcript with the last message if available, otherwise without message_id
63
+ # This ensures "Connection closed" is included in the transcript
64
+ save_transcript(@last_message_id) if @transcript_entries.any?
63
65
 
64
66
  super
65
67
  end
@@ -205,6 +207,7 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
205
207
 
206
208
  begin
207
209
  message_id = MailCatcher::Mail.add_message current_message
210
+ @last_message_id = message_id
208
211
  rescue => e
209
212
  $stderr.puts "Error in add_message: #{e.message}"
210
213
  $stderr.puts e.backtrace.join("\n")
@@ -213,16 +216,15 @@ class MailCatcher::Smtp < EventMachine::Protocols::SmtpServer
213
216
 
214
217
  MailCatcher::Mail.delete_older_messages!
215
218
 
216
- # Save transcript linked to message
217
- save_transcript(message_id)
219
+ # Don't save transcript here - save it when connection closes (in unbind)
220
+ # This ensures "Connection closed" entry is included
218
221
 
219
222
  true
220
223
  rescue => exception
221
224
  log_transcript('error', 'server', "Exception: #{exception.class} - #{exception.message}")
222
225
  MailCatcher.log_exception("Error receiving message", @current_message, exception)
223
226
 
224
- # Save transcript even on error
225
- save_transcript(nil)
227
+ # Don't save transcript here - save it when connection closes (in unbind)
226
228
 
227
229
  false
228
230
  ensure
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MailCatcher
4
- VERSION = '1.3.1'
4
+ VERSION = '1.4.0'
5
5
  end
@@ -101,9 +101,39 @@ module MailCatcher
101
101
  @fqdn = fqdn
102
102
  end
103
103
 
104
+ # Fetch all SMTP transcripts and flatten entries for display
105
+ transcripts = Mail.all_transcript_entries
106
+ @log_entries = transcripts.flat_map do |transcript|
107
+ transcript['entries'].map do |entry|
108
+ entry.merge({
109
+ 'session_id' => transcript['session_id'],
110
+ 'client_ip' => transcript['client_ip'],
111
+ 'server_port' => transcript['server_port'],
112
+ 'tls_enabled' => transcript['tls_enabled']
113
+ })
114
+ end
115
+ end.sort_by { |e| e['timestamp'] }
116
+
104
117
  erb :server_info
105
118
  end
106
119
 
120
+ get "/logs.json" do
121
+ content_type :json
122
+ transcripts = Mail.all_transcript_entries
123
+ log_entries = transcripts.flat_map do |transcript|
124
+ transcript['entries'].map do |entry|
125
+ entry.merge({
126
+ 'session_id' => transcript['session_id'],
127
+ 'client_ip' => transcript['client_ip'],
128
+ 'server_port' => transcript['server_port'],
129
+ 'tls_enabled' => transcript['tls_enabled']
130
+ })
131
+ end
132
+ end.sort_by { |e| e['timestamp'] }
133
+
134
+ JSON.generate(entries: log_entries)
135
+ end
136
+
107
137
  delete "/" do
108
138
  if MailCatcher.quittable?
109
139
  MailCatcher.quit!
data/lib/mail_catcher.rb CHANGED
@@ -85,6 +85,7 @@ module MailCatcher
85
85
  http_port: '1080',
86
86
  http_path: '/',
87
87
  messages_limit: nil,
88
+ persistence: false,
88
89
  verbose: false,
89
90
  daemon: !windows?,
90
91
  browse: false,
@@ -157,6 +158,10 @@ module MailCatcher
157
158
  options[:messages_limit] = count
158
159
  end
159
160
 
161
+ parser.on('--persistence', 'Store messages in a persistent SQLite database file') do
162
+ options[:persistence] = true
163
+ end
164
+
160
165
  parser.on('--http-path PATH', String, 'Add a prefix to all HTTP paths') do |path|
161
166
  clean_path = Rack::Utils.clean_path_info("/#{path}")
162
167
 
@@ -324,6 +324,37 @@ main {
324
324
  text-align: center;
325
325
  }
326
326
 
327
+ #messages th.sortable {
328
+ cursor: pointer;
329
+ user-select: none;
330
+ display: flex;
331
+ align-items: center;
332
+ gap: 6px;
333
+ transition: background-color 0.2s, color 0.2s;
334
+ }
335
+
336
+ #messages th.sortable:hover {
337
+ background-color: #f0f0f0;
338
+ color: #3f3f3f;
339
+ }
340
+
341
+ #messages th.sortable .sort-icon {
342
+ width: 14px;
343
+ height: 14px;
344
+ flex-shrink: 0;
345
+ opacity: 0.4;
346
+ transition: opacity 0.2s;
347
+ }
348
+
349
+ #messages th.sortable:hover .sort-icon {
350
+ opacity: 0.6;
351
+ }
352
+
353
+ #messages th.sortable.active .sort-icon {
354
+ opacity: 1;
355
+ color: #2196F3;
356
+ }
357
+
327
358
  #messages tbody {
328
359
  overflow-y: auto;
329
360
  flex: 1;