mailcatcher-ng 1.3.2 → 1.4.6

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: 94ba0ec31d08bb9451404c14a7da44e9915e5fcb61d979b78a194bb3554cb5b9
4
- data.tar.gz: f4c1bd7e99e918867538f43b8de1590e6b7430d581f23479df7e8324b6da0abf
3
+ metadata.gz: 2238cc35e17eb1bca222b4adf177bd093f9eacf291f5b179c3c1db7cc6ccfc53
4
+ data.tar.gz: 6d7487a8ef8f6488afa6f83e850c2c3f928424cd870ed313c15849c849873672
5
5
  SHA512:
6
- metadata.gz: 3cdd308d86e8005320a6837bf89c573593c33965eeb9908b871f1361303c2c65e4cff1b6dc7a3baf7dc8f6c80030bebe5bca325bde1e7b28a79cd21692b932bd
7
- data.tar.gz: 8fa8a3b1220b87a4f0d7b011c2b84b5033eb513648a4b25787b0367849e08bd07ce0a2d3f977db717c4007724c04b37e9ed249d67cdd5f74c307d513cec21f64
6
+ metadata.gz: f784c55cf0eb84d166a48ba99e36e60b3fd4028facf5e2b30fa92c3c6f17814f71434fb6e5be2f68a82c4c3f3b93c7cc2c617c5100a8515ba0e9d2a3331e3272
7
+ data.tar.gz: 776268ce8b6c14dcb67c5387e03611015c1dab6afc8527ef6a1bd67d42bd510d634eec220df5363625de4536f576e26fd53605140a00a2aaddac2e4190c1fe55
@@ -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,41 @@ 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(<<-SQL)
61
+ CREATE TABLE IF NOT EXISTS websocket_connection (
62
+ id INTEGER PRIMARY KEY ASC,
63
+ session_id TEXT NOT NULL,
64
+ client_ip TEXT,
65
+ opened_at DATETIME,
66
+ closed_at DATETIME,
67
+ last_ping_at DATETIME,
68
+ last_pong_at DATETIME,
69
+ ping_count INTEGER DEFAULT 0,
70
+ pong_count INTEGER DEFAULT 0,
71
+ created_at DATETIME DEFAULT CURRENT_DATETIME,
72
+ updated_at DATETIME DEFAULT CURRENT_DATETIME
73
+ )
74
+ SQL
75
+ db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_message_id ON smtp_transcript(message_id)")
76
+ db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_session_id ON smtp_transcript(session_id)")
77
+ db.execute("CREATE INDEX IF NOT EXISTS idx_websocket_connection_session_id ON websocket_connection(session_id)")
60
78
  db.execute("PRAGMA foreign_keys = ON")
61
79
  end
62
80
  end
63
81
  end
64
82
 
83
+ def determine_db_path
84
+ if MailCatcher.options && MailCatcher.options[:persistence]
85
+ # Use a persistent SQLite file in the user's home directory
86
+ db_dir = File.expand_path('~/.mailcatcher')
87
+ FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
88
+ File.join(db_dir, 'mailcatcher.db')
89
+ else
90
+ # Use in-memory database
91
+ ':memory:'
92
+ end
93
+ end
94
+
65
95
  def add_message(message)
66
96
  @add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")
67
97
 
@@ -311,7 +341,10 @@ module MailCatcher::Mail extend self
311
341
 
312
342
  def delete!
313
343
  @delete_all_messages_query ||= db.prepare "DELETE FROM message"
344
+ @delete_all_transcripts_query ||= db.prepare "DELETE FROM smtp_transcript"
345
+
314
346
  @delete_all_messages_query.execute
347
+ @delete_all_transcripts_query.execute
315
348
 
316
349
  EventMachine.next_tick do
317
350
  MailCatcher::Bus.push(type: "clear")
@@ -438,7 +471,7 @@ module MailCatcher::Mail extend self
438
471
 
439
472
  def message_transcript(message_id)
440
473
  @message_transcript_query ||= db.prepare(<<-SQL)
441
- SELECT id, session_id, client_ip, client_port,
474
+ SELECT id, message_id, session_id, client_ip, client_port,
442
475
  server_ip, server_port, tls_enabled, tls_protocol,
443
476
  tls_cipher, connection_started_at, connection_ended_at,
444
477
  entries, created_at
@@ -470,6 +503,60 @@ module MailCatcher::Mail extend self
470
503
  end
471
504
  end
472
505
 
506
+ def all_transcript_entries
507
+ @all_transcript_entries_query ||= db.prepare(<<-SQL)
508
+ SELECT id, message_id, session_id, client_ip, client_port,
509
+ server_ip, server_port, tls_enabled, tls_protocol,
510
+ tls_cipher, connection_started_at, connection_ended_at,
511
+ entries, created_at
512
+ FROM smtp_transcript
513
+ ORDER BY created_at DESC
514
+ SQL
515
+
516
+ @all_transcript_entries_query.execute.map do |row|
517
+ result = Hash[@all_transcript_entries_query.columns.zip(row)]
518
+ result['entries'] = JSON.parse(result['entries']) if result['entries']
519
+ result['tls_enabled'] = result['tls_enabled'] == 1
520
+ result
521
+ end
522
+ end
523
+
524
+ def create_websocket_connection(session_id, client_ip)
525
+ @create_ws_connection_query ||= db.prepare(<<-SQL)
526
+ INSERT INTO websocket_connection (session_id, client_ip, opened_at, created_at, updated_at)
527
+ VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))
528
+ SQL
529
+ @create_ws_connection_query.execute(session_id, client_ip)
530
+ db.last_insert_row_id
531
+ end
532
+
533
+ def close_websocket_connection(session_id)
534
+ @close_ws_connection_query ||= db.prepare(<<-SQL)
535
+ UPDATE websocket_connection
536
+ SET closed_at = datetime('now'), updated_at = datetime('now')
537
+ WHERE session_id = ? AND closed_at IS NULL
538
+ SQL
539
+ @close_ws_connection_query.execute(session_id)
540
+ end
541
+
542
+ def record_websocket_ping(session_id)
543
+ @record_ping_query ||= db.prepare(<<-SQL)
544
+ UPDATE websocket_connection
545
+ SET last_ping_at = datetime('now'), ping_count = ping_count + 1, updated_at = datetime('now')
546
+ WHERE session_id = ? AND closed_at IS NULL
547
+ SQL
548
+ @record_ping_query.execute(session_id)
549
+ end
550
+
551
+ def record_websocket_pong(session_id)
552
+ @record_pong_query ||= db.prepare(<<-SQL)
553
+ UPDATE websocket_connection
554
+ SET last_pong_at = datetime('now'), pong_count = pong_count + 1, updated_at = datetime('now')
555
+ WHERE session_id = ? AND closed_at IS NULL
556
+ SQL
557
+ @record_pong_query.execute(session_id)
558
+ end
559
+
473
560
  private
474
561
 
475
562
  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.2'
4
+ VERSION = '1.4.6'
5
5
  end
@@ -73,6 +73,13 @@ module MailCatcher
73
73
  erb :websocket_test
74
74
  end
75
75
 
76
+ get "/version.json" do
77
+ content_type :json
78
+ JSON.generate({
79
+ version: MailCatcher::VERSION
80
+ })
81
+ end
82
+
76
83
  get "/server-info" do
77
84
  @version = MailCatcher::VERSION
78
85
  @smtp_ip = MailCatcher.options[:smtp_ip]
@@ -101,9 +108,39 @@ module MailCatcher
101
108
  @fqdn = fqdn
102
109
  end
103
110
 
111
+ # Fetch all SMTP transcripts and flatten entries for display
112
+ transcripts = Mail.all_transcript_entries
113
+ @log_entries = transcripts.flat_map do |transcript|
114
+ transcript['entries'].map do |entry|
115
+ entry.merge({
116
+ 'session_id' => transcript['session_id'],
117
+ 'client_ip' => transcript['client_ip'],
118
+ 'server_port' => transcript['server_port'],
119
+ 'tls_enabled' => transcript['tls_enabled']
120
+ })
121
+ end
122
+ end.sort_by { |e| e['timestamp'] }
123
+
104
124
  erb :server_info
105
125
  end
106
126
 
127
+ get "/logs.json" do
128
+ content_type :json
129
+ transcripts = Mail.all_transcript_entries
130
+ log_entries = transcripts.flat_map do |transcript|
131
+ transcript['entries'].map do |entry|
132
+ entry.merge({
133
+ 'session_id' => transcript['session_id'],
134
+ 'client_ip' => transcript['client_ip'],
135
+ 'server_port' => transcript['server_port'],
136
+ 'tls_enabled' => transcript['tls_enabled']
137
+ })
138
+ end
139
+ end.sort_by { |e| e['timestamp'] }
140
+
141
+ JSON.generate(entries: log_entries)
142
+ end
143
+
107
144
  delete "/" do
108
145
  if MailCatcher.quittable?
109
146
  MailCatcher.quit!
@@ -116,11 +153,16 @@ module MailCatcher
116
153
  get "/messages" do
117
154
  if request.websocket?
118
155
  bus_subscription = nil
156
+ ping_timer = nil
157
+ session_id = SecureRandom.uuid
158
+ client_ip = request.ip
119
159
 
120
160
  ws = Faye::WebSocket.new(request.env)
121
161
 
122
162
  ws.on(:open) do |_|
123
- $stderr.puts "[WebSocket] Connection opened"
163
+ $stderr.puts "[WebSocket] Connection opened (session: #{session_id}, ip: #{client_ip})"
164
+ MailCatcher::Mail.create_websocket_connection(session_id, client_ip)
165
+
124
166
  bus_subscription = MailCatcher::Bus.subscribe do |message|
125
167
  begin
126
168
  $stderr.puts "[WebSocket] Sending message: #{message.inspect}"
@@ -130,15 +172,41 @@ module MailCatcher
130
172
  MailCatcher.log_exception("Error sending message through websocket", message, exception)
131
173
  end
132
174
  end
175
+
176
+ # Send initial ping and set up periodic ping timer (every 30 seconds)
177
+ ping_interval = 30
178
+ ping_timer = EventMachine.add_periodic_timer(ping_interval) do
179
+ begin
180
+ $stderr.puts "[WebSocket] Sending ping (session: #{session_id})"
181
+ MailCatcher::Mail.record_websocket_ping(session_id)
182
+ ws.send(JSON.generate({ type: "ping" }))
183
+ rescue => exception
184
+ $stderr.puts "[WebSocket] Error sending ping: #{exception.message}"
185
+ end
186
+ end
187
+ end
188
+
189
+ ws.on(:message) do |event|
190
+ begin
191
+ data = JSON.parse(event.data)
192
+ if data["type"] == "pong"
193
+ $stderr.puts "[WebSocket] Received pong (session: #{session_id})"
194
+ MailCatcher::Mail.record_websocket_pong(session_id)
195
+ end
196
+ rescue => exception
197
+ $stderr.puts "[WebSocket] Error processing message: #{exception.message}"
198
+ end
133
199
  end
134
200
 
135
201
  ws.on(:close) do |_|
136
- $stderr.puts "[WebSocket] Connection closed"
202
+ $stderr.puts "[WebSocket] Connection closed (session: #{session_id})"
203
+ EventMachine.cancel_timer(ping_timer) if ping_timer
137
204
  MailCatcher::Bus.unsubscribe(bus_subscription) if bus_subscription
205
+ MailCatcher::Mail.close_websocket_connection(session_id)
138
206
  end
139
207
 
140
208
  ws.on(:error) do |event|
141
- $stderr.puts "[WebSocket] WebSocket error: #{event}"
209
+ $stderr.puts "[WebSocket] WebSocket error: #{event} (session: #{session_id})"
142
210
  end
143
211
 
144
212
  ws.rack_response
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,
@@ -109,7 +110,7 @@ module MailCatcher
109
110
  parser.banner = 'Usage: mailcatcher [options]'
110
111
  parser.version = VERSION
111
112
  parser.separator ''
112
- parser.separator "MailCatcher v#{VERSION}"
113
+ parser.separator "MailCatcher NG v#{VERSION}"
113
114
  parser.separator ''
114
115
 
115
116
  parser.on('--ip IP', 'Set the ip address of both servers') do |ip|
@@ -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
 
@@ -342,15 +347,15 @@ module MailCatcher
342
347
 
343
348
  # Configure the STARTTLS Smtp class
344
349
  Smtp.class_variable_set(:@@parms, {
345
- starttls: :required,
346
- starttls_options: ssl_options
347
- })
350
+ starttls: :required,
351
+ starttls_options: ssl_options
352
+ })
348
353
 
349
354
  # Configure the Direct TLS SmtpTls class
350
355
  SmtpTls.class_variable_set(:@@parms, {
351
- starttls: :required,
352
- starttls_options: ssl_options
353
- })
356
+ starttls: :required,
357
+ starttls_options: ssl_options
358
+ })
354
359
 
355
360
  @ssl_options = ssl_options
356
361
  end
@@ -1 +1 @@
1
- pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
@@ -0,0 +1,102 @@
1
+ /*
2
+ * favcount.js v1.5.0
3
+ * http://chrishunt.co/favcount
4
+ * Dynamically updates the favicon with a number.
5
+ *
6
+ * Copyright 2013, Chris Hunt
7
+ * Released under the MIT license
8
+ */
9
+
10
+ (function(){
11
+ function Favcount(icon) {
12
+ this.icon = icon;
13
+ this.opacity = 0.4;
14
+ this.canvas = document.createElement('canvas');
15
+ this.font = "Helvetica, Arial, sans-serif";
16
+ }
17
+
18
+ Favcount.prototype.set = function(count) {
19
+ var self = this,
20
+ img = document.createElement('img');
21
+
22
+ if (self.canvas.getContext) {
23
+ img.crossOrigin = "anonymous";
24
+
25
+ img.onload = function() {
26
+ drawCanvas(self.canvas, self.opacity, self.font, img, normalize(count));
27
+ };
28
+
29
+ img.src = this.icon;
30
+ }
31
+ };
32
+
33
+ function normalize(count) {
34
+ count = Math.round(count);
35
+
36
+ if (isNaN(count) || count < 1) {
37
+ return '';
38
+ } else if (count < 10) {
39
+ return ' ' + count;
40
+ } else if (count > 99) {
41
+ return '99';
42
+ } else {
43
+ return count;
44
+ }
45
+ }
46
+
47
+ function drawCanvas(canvas, opacity, font, img, count) {
48
+ var head = document.getElementsByTagName('head')[0],
49
+ favicon = document.createElement('link'),
50
+ multiplier, fontSize, context, xOffset, yOffset, border, shadow;
51
+
52
+ favicon.rel = 'icon';
53
+
54
+ // Scale canvas elements based on favicon size
55
+ multiplier = img.width / 16;
56
+ fontSize = multiplier * 11;
57
+ xOffset = multiplier;
58
+ yOffset = multiplier * 11;
59
+ border = multiplier;
60
+ shadow = multiplier * 2;
61
+
62
+ canvas.height = canvas.width = img.width;
63
+ context = canvas.getContext('2d');
64
+ context.font = 'bold ' + fontSize + 'px ' + font;
65
+
66
+ // Draw faded favicon background
67
+ if (count) { context.globalAlpha = opacity; }
68
+ context.drawImage(img, 0, 0);
69
+ context.globalAlpha = 1.0;
70
+
71
+ // Draw white drop shadow
72
+ context.shadowColor = '#FFF';
73
+ context.shadowBlur = shadow;
74
+ context.shadowOffsetX = 0;
75
+ context.shadowOffsetY = 0;
76
+
77
+ // Draw white border
78
+ context.fillStyle = '#FFF';
79
+ context.fillText(count, xOffset, yOffset);
80
+ context.fillText(count, xOffset + border, yOffset);
81
+ context.fillText(count, xOffset, yOffset + border);
82
+ context.fillText(count, xOffset + border, yOffset + border);
83
+
84
+ // Draw black count
85
+ context.fillStyle = '#000';
86
+ context.fillText(count,
87
+ xOffset + (border / 2.0),
88
+ yOffset + (border / 2.0)
89
+ );
90
+
91
+ // Replace favicon with new favicon
92
+ favicon.href = canvas.toDataURL('image/png');
93
+ head.removeChild(document.querySelector('link[rel=icon]'));
94
+ head.appendChild(favicon);
95
+ }
96
+
97
+ this.Favcount = Favcount;
98
+ }).call(this);
99
+
100
+ (function(){
101
+ Favcount.VERSION = '1.5.0';
102
+ }).call(this);