mailcatcher-ng 1.3.2 → 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: 94ba0ec31d08bb9451404c14a7da44e9915e5fcb61d979b78a194bb3554cb5b9
4
- data.tar.gz: f4c1bd7e99e918867538f43b8de1590e6b7430d581f23479df7e8324b6da0abf
3
+ metadata.gz: 6539793c4e31686b97ba0502cc4c48c47c49ace16230fea4cf2f2cb8d06704c7
4
+ data.tar.gz: d74ed846c8205f2b37b9b433b618424929d12515a928d23b4a481fe03a5993d1
5
5
  SHA512:
6
- metadata.gz: 3cdd308d86e8005320a6837bf89c573593c33965eeb9908b871f1361303c2c65e4cff1b6dc7a3baf7dc8f6c80030bebe5bca325bde1e7b28a79cd21692b932bd
7
- data.tar.gz: 8fa8a3b1220b87a4f0d7b011c2b84b5033eb513648a4b25787b0367849e08bd07ce0a2d3f977db717c4007724c04b37e9ed249d67cdd5f74c307d513cec21f64
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.2'
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
 
@@ -4,6 +4,10 @@
4
4
  <title>Server Information - MailCatcher</title>
5
5
  <base href="<%= settings.prefix.chomp("/") %>/">
6
6
  <link href="favicon.ico" rel="icon">
7
+ <!-- Tippy.js v6 for session ID tooltips -->
8
+ <script src="https://unpkg.com/@popperjs/core@2"></script>
9
+ <script src="https://unpkg.com/tippy.js@6"></script>
10
+ <link rel="stylesheet" href="https://unpkg.com/tippy.js@6/themes/light.css">
7
11
  <style>
8
12
  * {
9
13
  margin: 0;
@@ -16,37 +20,57 @@
16
20
  background: #f5f5f5;
17
21
  color: #1a1a1a;
18
22
  min-height: 100vh;
23
+ display: block;
24
+ padding: 0;
25
+ }
26
+
27
+ .page-container {
28
+ display: grid;
29
+ grid-template-columns: 450px 1fr;
30
+ gap: 32px;
31
+ max-width: 1600px;
32
+ margin: 0 auto;
33
+ padding: 40px;
34
+ min-height: 100vh;
35
+ }
36
+
37
+ .left-column {
19
38
  display: flex;
20
- align-items: center;
21
- justify-content: center;
22
- padding: 20px;
39
+ flex-direction: column;
40
+ gap: 24px;
23
41
  }
24
42
 
25
- .container {
43
+ .right-column {
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 24px;
47
+ }
48
+
49
+ .info-block {
26
50
  background: white;
27
51
  border-radius: 8px;
28
- padding: 40px;
52
+ padding: 32px;
29
53
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
30
- max-width: 700px;
31
- width: 100%;
32
54
  }
33
55
 
34
56
  h1 {
35
57
  font-size: 24px;
36
58
  font-weight: 700;
37
- margin: 0 0 10px 0;
59
+ margin: 0 0 4px 0;
60
+ color: #1a1a1a;
61
+ }
62
+
63
+ h2 {
64
+ font-size: 18px;
65
+ font-weight: 600;
66
+ margin: 0;
38
67
  color: #1a1a1a;
39
68
  }
40
69
 
41
70
  .page-subtitle {
42
71
  font-size: 13px;
43
72
  color: #666;
44
- margin-bottom: 32px;
45
- margin-top: 4px;
46
- }
47
-
48
- .info-section {
49
- margin-bottom: 32px;
73
+ margin-bottom: 24px;
50
74
  }
51
75
 
52
76
  .section-title {
@@ -63,12 +87,12 @@
63
87
  .info-grid {
64
88
  display: grid;
65
89
  grid-template-columns: 1fr 1fr;
66
- gap: 16px;
67
- margin-bottom: 16px;
90
+ gap: 12px;
91
+ margin-bottom: 0;
68
92
  }
69
93
 
70
94
  .info-item {
71
- padding: 12px 14px;
95
+ padding: 10px 12px;
72
96
  background: #f9f9f9;
73
97
  border-left: 3px solid #2196F3;
74
98
  border-radius: 4px;
@@ -79,26 +103,44 @@
79
103
  }
80
104
 
81
105
  .info-label {
82
- font-size: 11px;
106
+ font-size: 10px;
83
107
  font-weight: 600;
84
108
  color: #666;
85
109
  text-transform: uppercase;
86
110
  letter-spacing: 0.5px;
87
111
  display: block;
88
- margin-bottom: 6px;
112
+ margin-bottom: 4px;
89
113
  }
90
114
 
91
115
  .info-value {
92
116
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Courier New', monospace;
93
- font-size: 14px;
117
+ font-size: 13px;
94
118
  color: #1a1a1a;
95
119
  word-break: break-all;
96
120
  }
97
121
 
122
+ .settings-placeholder {
123
+ text-align: center;
124
+ padding: 48px 32px;
125
+ background: #fafafa;
126
+ border: 2px dashed #e8eaed;
127
+ border-radius: 8px;
128
+ }
129
+
130
+ .settings-placeholder .section-title {
131
+ border-bottom: none;
132
+ margin-bottom: 12px;
133
+ }
134
+
135
+ .coming-soon {
136
+ font-size: 13px;
137
+ color: #999;
138
+ margin: 0;
139
+ }
140
+
98
141
  .button-group {
99
142
  display: flex;
100
143
  gap: 12px;
101
- margin-top: 32px;
102
144
  flex-wrap: wrap;
103
145
  }
104
146
 
@@ -149,104 +191,550 @@
149
191
  .version-info {
150
192
  font-size: 12px;
151
193
  color: #999;
152
- margin-top: 24px;
153
- padding-top: 16px;
154
- border-top: 1px solid #e8eaed;
155
194
  text-align: center;
156
195
  }
196
+
197
+ /* Logs panel styles */
198
+ .logs-panel {
199
+ background: white;
200
+ border-radius: 8px;
201
+ padding: 32px;
202
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 16px;
206
+ flex: 1;
207
+ min-height: 600px;
208
+ }
209
+
210
+ .log-search-box {
211
+ position: relative;
212
+ }
213
+
214
+ .log-search-box input {
215
+ width: 100%;
216
+ padding: 10px 36px 10px 12px;
217
+ border: 1px solid #e0e0e0;
218
+ border-radius: 6px;
219
+ font-size: 13px;
220
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto';
221
+ background: #ffffff;
222
+ }
223
+
224
+ .log-search-box input:focus {
225
+ outline: none;
226
+ border-color: #2196F3;
227
+ box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
228
+ }
229
+
230
+ .log-search-clear {
231
+ position: absolute;
232
+ right: 10px;
233
+ top: 50%;
234
+ transform: translateY(-50%);
235
+ background: none;
236
+ border: none;
237
+ color: #999;
238
+ cursor: pointer;
239
+ display: none;
240
+ font-size: 18px;
241
+ padding: 0;
242
+ width: 20px;
243
+ height: 20px;
244
+ }
245
+
246
+ .log-search-clear:hover {
247
+ color: #666;
248
+ }
249
+
250
+ .log-container {
251
+ background: #f9f9f9;
252
+ border: 1px solid #e8eaed;
253
+ border-radius: 6px;
254
+ padding: 16px;
255
+ max-height: 600px;
256
+ overflow-y: auto;
257
+ font-family: 'Monaco', 'Courier New', monospace;
258
+ flex: 1;
259
+ }
260
+
261
+ .log-entry {
262
+ display: flex;
263
+ gap: 12px;
264
+ margin: 6px 0;
265
+ padding: 4px 0;
266
+ border-bottom: 1px solid #f0f0f0;
267
+ font-size: 12px;
268
+ align-items: flex-start;
269
+ }
270
+
271
+ .log-entry:last-child {
272
+ border-bottom: none;
273
+ }
274
+
275
+ .log-entry.hidden {
276
+ display: none;
277
+ }
278
+
279
+ .log-meta {
280
+ display: flex;
281
+ gap: 8px;
282
+ flex-shrink: 0;
283
+ align-items: center;
284
+ }
285
+
286
+ .log-time {
287
+ color: #999;
288
+ min-width: 100px;
289
+ font-size: 11px;
290
+ }
291
+
292
+ .log-session {
293
+ color: #999;
294
+ font-size: 10px;
295
+ max-width: 150px;
296
+ overflow: hidden;
297
+ text-overflow: ellipsis;
298
+ white-space: nowrap;
299
+ cursor: help;
300
+ border-bottom: 1px dotted #ddd;
301
+ }
302
+
303
+ .log-type {
304
+ min-width: 80px;
305
+ flex-shrink: 0;
306
+ font-size: 11px;
307
+ font-weight: 600;
308
+ text-transform: uppercase;
309
+ }
310
+
311
+ .log-type.connection { color: #9c27b0; }
312
+ .log-type.command { color: #2196F3; }
313
+ .log-type.response { color: #34a853; }
314
+ .log-type.tls { color: #ff9800; }
315
+ .log-type.data { color: #607d8b; }
316
+ .log-type.error { color: #f44336; }
317
+
318
+ .log-direction {
319
+ min-width: 60px;
320
+ flex-shrink: 0;
321
+ font-size: 11px;
322
+ color: #666;
323
+ }
324
+
325
+ .log-direction.client::before {
326
+ content: '→ ';
327
+ color: #2196F3;
328
+ }
329
+
330
+ .log-direction.server::before {
331
+ content: '← ';
332
+ color: #34a853;
333
+ }
334
+
335
+ .log-message {
336
+ flex: 1;
337
+ color: #1a1a1a;
338
+ font-size: 12px;
339
+ word-break: break-word;
340
+ white-space: pre-wrap;
341
+ }
342
+
343
+ .log-message.error {
344
+ color: #f44336;
345
+ font-weight: 500;
346
+ }
347
+
348
+ .no-logs {
349
+ text-align: center;
350
+ padding: 40px 20px;
351
+ color: #999;
352
+ font-size: 14px;
353
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto';
354
+ }
355
+
356
+ /* Session ID tooltip styles */
357
+ .session-tooltip-content {
358
+ display: flex;
359
+ align-items: center;
360
+ gap: 8px;
361
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', monospace;
362
+ font-size: 12px;
363
+ padding: 0;
364
+ }
365
+
366
+ .session-id-value {
367
+ background: #f5f5f5;
368
+ padding: 6px 8px;
369
+ border-radius: 4px;
370
+ font-family: 'Monaco', 'Courier New', monospace;
371
+ color: #1a1a1a;
372
+ user-select: all;
373
+ }
374
+
375
+ .copy-session-btn {
376
+ background: none;
377
+ border: none;
378
+ cursor: pointer;
379
+ padding: 4px;
380
+ display: flex;
381
+ align-items: center;
382
+ color: #2196F3;
383
+ transition: all 0.2s;
384
+ }
385
+
386
+ .copy-session-btn:hover {
387
+ color: #1976D2;
388
+ }
389
+
390
+ .copy-session-btn svg {
391
+ width: 14px;
392
+ height: 14px;
393
+ }
394
+
395
+ .copy-session-btn.copied {
396
+ color: #34a853;
397
+ }
157
398
  </style>
158
399
  </head>
159
400
  <body>
160
- <div class="container">
161
- <h1>Server Information</h1>
162
- <div class="page-subtitle">Configuration and connection details</div>
401
+ <div class="page-container">
402
+ <!-- LEFT COLUMN -->
403
+ <div class="left-column">
404
+ <div class="info-block">
405
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px;">
406
+ <div>
407
+ <h1>Server Information</h1>
408
+ <div class="page-subtitle">Configuration</div>
409
+ </div>
410
+ <a href="<%= settings.prefix.chomp("/") %>/" class="btn btn-primary" style="white-space: nowrap; margin-left: 16px;">
411
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
412
+ <line x1="19" y1="12" x2="5" y2="12"></line>
413
+ <polyline points="12 19 5 12 12 5"></polyline>
414
+ </svg>
415
+ Back
416
+ </a>
417
+ </div>
418
+
419
+ <div class="section-title">Network</div>
420
+ <div class="info-grid">
421
+ <div class="info-item">
422
+ <span class="info-label">Hostname</span>
423
+ <div class="info-value"><%= @hostname %></div>
424
+ </div>
425
+
426
+ <div class="info-item">
427
+ <span class="info-label">FQDN</span>
428
+ <div class="info-value"><%= @fqdn %></div>
429
+ </div>
430
+ </div>
163
431
 
164
- <div class="info-section">
165
- <div class="section-title">Network Configuration</div>
432
+ <div class="section-title" style="margin-top: 16px;">SMTP Server</div>
433
+ <div class="info-grid">
434
+ <div class="info-item">
435
+ <span class="info-label">IP Address</span>
436
+ <div class="info-value"><%= @smtp_ip %></div>
437
+ </div>
438
+
439
+ <div class="info-item">
440
+ <span class="info-label">Port</span>
441
+ <div class="info-value"><%= @smtp_port %></div>
442
+ </div>
443
+ </div>
166
444
 
167
- <div class="info-item full">
168
- <span class="info-label">Hostname</span>
169
- <div class="info-value"><%= @hostname %></div>
445
+ <div class="section-title" style="margin-top: 16px;">HTTP Server</div>
446
+ <div class="info-grid">
447
+ <div class="info-item">
448
+ <span class="info-label">IP Address</span>
449
+ <div class="info-value"><%= @http_ip %></div>
450
+ </div>
451
+
452
+ <div class="info-item">
453
+ <span class="info-label">Port</span>
454
+ <div class="info-value"><%= @http_port %></div>
455
+ </div>
456
+
457
+ <div class="info-item full">
458
+ <span class="info-label">Base Path</span>
459
+ <div class="info-value"><%= @http_path %></div>
460
+ </div>
461
+ </div>
170
462
  </div>
171
463
 
172
- <div class="info-item full">
173
- <span class="info-label">FQDN (Fully Qualified Domain Name)</span>
174
- <div class="info-value"><%= @fqdn %></div>
464
+ <div class="info-block settings-placeholder">
465
+ <div class="section-title">Server Settings</div>
466
+ <p class="coming-soon">Coming soon</p>
467
+ <div class="button-group" style="margin-top: 16px; justify-content: center;">
468
+ <a href="<%= File.join(settings.prefix.chomp("/"), "websocket-test") %>" class="btn btn-secondary">
469
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
470
+ <circle cx="12" cy="12" r="1"></circle>
471
+ <path d="M12 1v6m0 6v4"></path>
472
+ <path d="M4.22 4.22l4.24 4.24m5.08 5.08l4.24 4.24"></path>
473
+ <path d="M1 12h6m6 0h4"></path>
474
+ <path d="M4.22 19.78l4.24-4.24m5.08-5.08l4.24-4.24"></path>
475
+ </svg>
476
+ Diagnostics
477
+ </a>
478
+ </div>
479
+ <div class="version-info" style="margin-top: 12px;">
480
+ MailCatcher v<%= @version %>
481
+ </div>
175
482
  </div>
176
483
  </div>
177
484
 
178
- <div class="info-section">
179
- <div class="section-title">Active Connections</div>
485
+ <!-- RIGHT COLUMN -->
486
+ <div class="right-column">
487
+ <div class="logs-panel">
488
+ <div style="display: flex; justify-content: space-between; align-items: center;">
489
+ <h2>Server Logs</h2>
490
+ <button id="autoRefreshBtn" class="btn btn-secondary" style="padding: 8px 12px; font-size: 12px;">
491
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
492
+ <path d="M21.5 2v6h-6"></path>
493
+ <path d="M2.5 22v-6h6"></path>
494
+ <path d="M2 11.5a10 10 0 0 1 18.8-4.3"></path>
495
+ <path d="M22 12.5a10 10 0 0 1-18.8 4.2"></path>
496
+ </svg>
497
+ Auto-refresh: ON
498
+ </button>
499
+ </div>
180
500
 
181
- <div class="info-grid">
182
- <div class="info-item">
183
- <span class="info-label">HTTP Connections</span>
184
- <div class="info-value"><%= @http_connections %></div>
501
+ <div class="log-search-box">
502
+ <input type="text" id="logSearch" placeholder="Filter logs..." />
503
+ <button class="log-search-clear" id="logSearchClear">×</button>
185
504
  </div>
186
505
 
187
- <div class="info-item">
188
- <span class="info-label">SMTP Connections</span>
189
- <div class="info-value"><%= @smtp_connections %></div>
506
+ <div class="log-container" id="logContainer">
507
+ <div class="no-logs">
508
+ Loading logs...
509
+ </div>
190
510
  </div>
191
511
  </div>
192
512
  </div>
513
+ </div>
193
514
 
194
- <div class="info-section">
195
- <div class="section-title">SMTP Server</div>
196
-
197
- <div class="info-item">
198
- <span class="info-label">IP Address</span>
199
- <div class="info-value"><%= @smtp_ip %></div>
200
- </div>
515
+ <script>
516
+ // Real-time log refresh system
517
+ var logContainer = document.getElementById('logContainer');
518
+ var searchInput = document.getElementById('logSearch');
519
+ var searchClear = document.getElementById('logSearchClear');
520
+ var autoRefreshBtn = document.getElementById('autoRefreshBtn');
521
+
522
+ var autoRefreshEnabled = true;
523
+ var refreshInterval = null;
524
+ var lastLogCount = 0;
525
+ var allLogEntries = [];
526
+
527
+ // Fetch logs from server
528
+ function fetchLogs() {
529
+ fetch('<%= settings.prefix.chomp("/") %>/logs.json')
530
+ .then(function(response) {
531
+ return response.json();
532
+ })
533
+ .then(function(data) {
534
+ allLogEntries = data.entries || [];
535
+ renderLogs();
536
+ })
537
+ .catch(function(error) {
538
+ console.error('Error fetching logs:', error);
539
+ });
540
+ }
201
541
 
202
- <div class="info-item">
203
- <span class="info-label">Port</span>
204
- <div class="info-value"><%= @smtp_port %></div>
205
- </div>
206
- </div>
542
+ // Render logs with current search filter applied
543
+ function renderLogs() {
544
+ var query = searchInput.value.toLowerCase().trim();
545
+
546
+ logContainer.innerHTML = '';
547
+
548
+ if (allLogEntries.length === 0) {
549
+ logContainer.innerHTML = '<div class="no-logs">No server logs available</div>';
550
+ return;
551
+ }
552
+
553
+ allLogEntries.forEach(function(entry) {
554
+ var searchable = JSON.stringify(entry).toLowerCase();
555
+ var shouldShow = !query || searchable.indexOf(query) >= 0;
556
+
557
+ var timeStr = '';
558
+ try {
559
+ var time = new Date(entry.timestamp);
560
+ var hours = String(time.getHours()).padStart(2, '0');
561
+ var minutes = String(time.getMinutes()).padStart(2, '0');
562
+ var seconds = String(time.getSeconds()).padStart(2, '0');
563
+ var ms = String(time.getMilliseconds()).padStart(3, '0');
564
+ timeStr = hours + ':' + minutes + ':' + seconds + '.' + ms;
565
+ } catch (e) {
566
+ timeStr = '??:??:??';
567
+ }
568
+
569
+ var sessionId = entry.session_id || '';
570
+ var sessionDisplay = sessionId.substring(0, 8) + '...';
571
+
572
+ var entryDiv = document.createElement('div');
573
+ entryDiv.className = 'log-entry' + (shouldShow ? '' : ' hidden');
574
+ entryDiv.setAttribute('data-searchable', searchable);
575
+ entryDiv.innerHTML =
576
+ '<div class="log-meta">' +
577
+ '<div class="log-time">' + timeStr + '</div>' +
578
+ '<div class="log-session" data-session-id="' + escapeHtml(sessionId) + '">' + sessionDisplay + '</div>' +
579
+ '</div>' +
580
+ '<div class="log-type ' + entry.type + '">' + entry.type + '</div>' +
581
+ '<div class="log-direction ' + entry.direction + '">' + entry.direction + '</div>' +
582
+ '<div class="log-message' + (entry.type === 'error' ? ' error' : '') + '">' +
583
+ escapeHtml(entry.message) +
584
+ '</div>';
585
+
586
+ logContainer.appendChild(entryDiv);
587
+ });
588
+
589
+ // Initialize Tippy tooltips for session IDs
590
+ initSessionTooltips();
591
+
592
+ // Auto-scroll to bottom
593
+ logContainer.scrollTop = logContainer.scrollHeight;
594
+ }
207
595
 
208
- <div class="info-section">
209
- <div class="section-title">HTTP Server</div>
596
+ // HTML escape utility
597
+ function escapeHtml(text) {
598
+ var map = {
599
+ '&': '&amp;',
600
+ '<': '&lt;',
601
+ '>': '&gt;',
602
+ '"': '&quot;',
603
+ "'": '&#039;'
604
+ };
605
+ return text.replace(/[&<>"']/g, function(m) { return map[m]; });
606
+ }
210
607
 
211
- <div class="info-item">
212
- <span class="info-label">IP Address</span>
213
- <div class="info-value"><%= @http_ip %></div>
214
- </div>
608
+ // Filter logs based on search input
609
+ function filterEntries() {
610
+ renderLogs();
611
+ searchClear.style.display = searchInput.value ? 'block' : 'none';
612
+ }
215
613
 
216
- <div class="info-item">
217
- <span class="info-label">Port</span>
218
- <div class="info-value"><%= @http_port %></div>
219
- </div>
614
+ // Toggle auto-refresh
615
+ function toggleAutoRefresh() {
616
+ autoRefreshEnabled = !autoRefreshEnabled;
617
+
618
+ if (autoRefreshEnabled) {
619
+ autoRefreshBtn.textContent = '';
620
+ autoRefreshBtn.innerHTML =
621
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">' +
622
+ '<path d="M21.5 2v6h-6"></path>' +
623
+ '<path d="M2.5 22v-6h6"></path>' +
624
+ '<path d="M2 11.5a10 10 0 0 1 18.8-4.3"></path>' +
625
+ '<path d="M22 12.5a10 10 0 0 1-18.8 4.2"></path>' +
626
+ '</svg> Auto-refresh: ON';
627
+
628
+ // Fetch any missed logs immediately
629
+ fetchLogs();
630
+
631
+ // Resume auto-refresh interval
632
+ refreshInterval = setInterval(function() {
633
+ fetchLogs();
634
+ }, 1000);
635
+ } else {
636
+ autoRefreshBtn.textContent = '';
637
+ autoRefreshBtn.innerHTML =
638
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">' +
639
+ '<path d="M21.5 2v6h-6"></path>' +
640
+ '<path d="M2.5 22v-6h6"></path>' +
641
+ '<path d="M2 11.5a10 10 0 0 1 18.8-4.3"></path>' +
642
+ '<path d="M22 12.5a10 10 0 0 1-18.8 4.2"></path>' +
643
+ '</svg> Auto-refresh: OFF';
644
+
645
+ // Stop auto-refresh interval
646
+ if (refreshInterval) {
647
+ clearInterval(refreshInterval);
648
+ refreshInterval = null;
649
+ }
650
+ }
651
+ }
220
652
 
221
- <div class="info-item full">
222
- <span class="info-label">Base Path</span>
223
- <div class="info-value"><%= @http_path %></div>
224
- </div>
225
- </div>
653
+ // Initialize Tippy tooltips for session IDs
654
+ function initSessionTooltips() {
655
+ var sessionElements = document.querySelectorAll('.log-session[data-session-id]');
656
+ sessionElements.forEach(function(element) {
657
+ // Destroy existing tooltip if it exists
658
+ if (element._tippy) {
659
+ element._tippy.destroy();
660
+ }
661
+
662
+ var sessionId = element.getAttribute('data-session-id');
663
+
664
+ // Create tooltip content
665
+ var tooltipDiv = document.createElement('div');
666
+ tooltipDiv.className = 'session-tooltip-content';
667
+ tooltipDiv.innerHTML =
668
+ '<div class="session-id-value">' + escapeHtml(sessionId) + '</div>' +
669
+ '<button class="copy-session-btn" data-session-id="' + escapeHtml(sessionId) + '">' +
670
+ '<svg class="copy-icon" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
671
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"></path>' +
672
+ '</svg>' +
673
+ '<svg class="checkmark-icon" style="display: none;" data-slot="icon" fill="none" stroke-width="1.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
674
+ '<path stroke-linecap="round" stroke-linejoin="round" d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75"></path>' +
675
+ '</svg>' +
676
+ '</button>';
677
+
678
+ // Create Tippy instance
679
+ tippy(element, {
680
+ content: tooltipDiv,
681
+ theme: 'light',
682
+ placement: 'top',
683
+ interactive: true,
684
+ onShow: function(instance) {
685
+ // Attach click handler when tooltip shows
686
+ var copyBtn = tooltipDiv.querySelector('.copy-session-btn');
687
+ if (copyBtn && !copyBtn._clickHandlerAttached) {
688
+ copyBtn._clickHandlerAttached = true;
689
+ copyBtn.addEventListener('click', function(e) {
690
+ e.preventDefault();
691
+ var sessionIdToCopy = copyBtn.getAttribute('data-session-id');
692
+ copyToClipboard(sessionIdToCopy, copyBtn);
693
+ });
694
+ }
695
+ }
696
+ });
697
+ });
698
+ }
226
699
 
227
- <div class="button-group">
228
- <a href="<%= settings.prefix.chomp("/") %>/" class="btn btn-primary">
229
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
230
- <line x1="19" y1="12" x2="5" y2="12"></line>
231
- <polyline points="12 19 5 12 12 5"></polyline>
232
- </svg>
233
- Back to Inbox
234
- </a>
235
- <a href="<%= File.join(settings.prefix.chomp("/"), "websocket-test") %>" class="btn btn-secondary">
236
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
237
- <circle cx="12" cy="12" r="1"></circle>
238
- <path d="M12 1v6m0 6v4"></path>
239
- <path d="M4.22 4.22l4.24 4.24m5.08 5.08l4.24 4.24"></path>
240
- <path d="M1 12h6m6 0h4"></path>
241
- <path d="M4.22 19.78l4.24-4.24m5.08-5.08l4.24-4.24"></path>
242
- </svg>
243
- Diagnostics
244
- </a>
245
- </div>
700
+ // Copy session ID to clipboard
701
+ function copyToClipboard(text, button) {
702
+ navigator.clipboard.writeText(text).then(function() {
703
+ var copyIcon = button.querySelector('.copy-icon');
704
+ var checkmarkIcon = button.querySelector('.checkmark-icon');
705
+
706
+ // Show checkmark
707
+ if (copyIcon) copyIcon.style.display = 'none';
708
+ if (checkmarkIcon) checkmarkIcon.style.display = 'block';
709
+ button.classList.add('copied');
710
+
711
+ // Reset after 2 seconds
712
+ setTimeout(function() {
713
+ if (copyIcon) copyIcon.style.display = 'block';
714
+ if (checkmarkIcon) checkmarkIcon.style.display = 'none';
715
+ button.classList.remove('copied');
716
+ }, 2000);
717
+ }).catch(function(err) {
718
+ console.error('Failed to copy:', err);
719
+ });
720
+ }
246
721
 
247
- <div class="version-info">
248
- MailCatcher v<%= @version %>
249
- </div>
250
- </div>
722
+ // Event listeners
723
+ searchInput.addEventListener('keyup', filterEntries);
724
+ searchClear.addEventListener('click', function() {
725
+ searchInput.value = '';
726
+ filterEntries();
727
+ searchInput.focus();
728
+ });
729
+ autoRefreshBtn.addEventListener('click', toggleAutoRefresh);
730
+
731
+ // Initial load and setup auto-refresh
732
+ fetchLogs();
733
+ refreshInterval = setInterval(function() {
734
+ if (autoRefreshEnabled) {
735
+ fetchLogs();
736
+ }
737
+ }, 1000); // Fetch every 1 second
738
+ </script>
251
739
  </body>
252
740
  </html>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailcatcher-ng
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.2
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephane Paquet