mailcatcher-ng 1.4.0 → 1.5.2

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.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail_catcher/integrations/mcp_tools"
4
+ require "mail_catcher/integrations/mcp_server"
5
+
6
+ module MailCatcher
7
+ # Integration manager for optional features like MCP and Claude Plugins
8
+ module Integrations
9
+ extend self
10
+
11
+ attr_accessor :mcp_server
12
+
13
+ def initialize
14
+ @mcp_server = nil
15
+ end
16
+
17
+ # Start integrations based on options
18
+ def start(options = {})
19
+ $stderr.puts "[Integrations] Starting integrations with options: #{options.inspect}"
20
+
21
+ if options[:mcp_enabled]
22
+ start_mcp_server(options)
23
+ end
24
+ end
25
+
26
+ # Start the MCP server
27
+ def start_mcp_server(options = {})
28
+ $stderr.puts "[Integrations] Starting MCP server"
29
+ @mcp_server = MCPServer.new(options)
30
+
31
+ # Run MCP server in a separate thread
32
+ Thread.new do
33
+ begin
34
+ @mcp_server.run
35
+ rescue => e
36
+ $stderr.puts "[Integrations] MCP server error: #{e.message}"
37
+ $stderr.puts e.backtrace
38
+ end
39
+ end
40
+
41
+ # Give the server a moment to start
42
+ sleep 0.1
43
+ $stderr.puts "[Integrations] MCP server started"
44
+ end
45
+
46
+ # Stop all integrations
47
+ def stop
48
+ $stderr.puts "[Integrations] Stopping integrations"
49
+ @mcp_server&.stop
50
+ @mcp_server = nil
51
+ end
52
+
53
+ # Check if MCP server is running
54
+ def mcp_running?
55
+ @mcp_server&.running
56
+ end
57
+
58
+ # Get available tools
59
+ def available_tools
60
+ MCPTools.tool_names
61
+ end
62
+ end
63
+ end
@@ -4,7 +4,9 @@ require "eventmachine"
4
4
  require "fileutils"
5
5
  require "json"
6
6
  require "mail"
7
+ require "nokogiri"
7
8
  require "sqlite3"
9
+ require "uri"
8
10
 
9
11
  module MailCatcher::Mail extend self
10
12
  def db
@@ -57,8 +59,24 @@ module MailCatcher::Mail extend self
57
59
  FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
58
60
  )
59
61
  SQL
62
+ db.execute(<<-SQL)
63
+ CREATE TABLE IF NOT EXISTS websocket_connection (
64
+ id INTEGER PRIMARY KEY ASC,
65
+ session_id TEXT NOT NULL,
66
+ client_ip TEXT,
67
+ opened_at DATETIME,
68
+ closed_at DATETIME,
69
+ last_ping_at DATETIME,
70
+ last_pong_at DATETIME,
71
+ ping_count INTEGER DEFAULT 0,
72
+ pong_count INTEGER DEFAULT 0,
73
+ created_at DATETIME DEFAULT CURRENT_DATETIME,
74
+ updated_at DATETIME DEFAULT CURRENT_DATETIME
75
+ )
76
+ SQL
60
77
  db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_message_id ON smtp_transcript(message_id)")
61
78
  db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_session_id ON smtp_transcript(session_id)")
79
+ db.execute("CREATE INDEX IF NOT EXISTS idx_websocket_connection_session_id ON websocket_connection(session_id)")
62
80
  db.execute("PRAGMA foreign_keys = ON")
63
81
  end
64
82
  end
@@ -118,6 +136,62 @@ module MailCatcher::Mail extend self
118
136
  end
119
137
  end
120
138
 
139
+ def search_messages(query: nil, has_attachments: nil, from_date: nil, to_date: nil)
140
+ # Build dynamic SQL query with filters
141
+ sql = "SELECT DISTINCT m.id, m.sender, m.recipients, m.subject, m.size, m.created_at FROM message m"
142
+ params = []
143
+ where_clauses = []
144
+
145
+ # Determine if we need to join with message_part table
146
+ needs_join = (query && !query.strip.empty?) || has_attachments.is_a?(TrueClass)
147
+
148
+ # Join with message_part table if needed
149
+ if needs_join
150
+ sql += " LEFT JOIN message_part mp ON m.id = mp.message_id"
151
+ end
152
+
153
+ # Add search filters - search across subject, sender, and recipients (always available)
154
+ # Also search body if we have the JOIN
155
+ if query && !query.strip.empty?
156
+ q = "%#{query}%"
157
+ if needs_join
158
+ where_clauses << "(m.subject LIKE ? OR m.sender LIKE ? OR m.recipients LIKE ? OR mp.body LIKE ?)"
159
+ params.concat([q, q, q, q])
160
+ else
161
+ where_clauses << "(m.subject LIKE ? OR m.sender LIKE ? OR m.recipients LIKE ?)"
162
+ params.concat([q, q, q])
163
+ end
164
+ end
165
+
166
+ # Add attachment filter
167
+ if has_attachments.is_a?(TrueClass)
168
+ where_clauses << "(mp.is_attachment = 1)"
169
+ end
170
+
171
+ # Add date range filters
172
+ if from_date
173
+ where_clauses << "(m.created_at >= ?)"
174
+ params << from_date
175
+ end
176
+
177
+ if to_date
178
+ where_clauses << "(m.created_at <= ?)"
179
+ params << to_date
180
+ end
181
+
182
+ # Combine where clauses
183
+ sql += " WHERE #{where_clauses.join(' AND ')}" if where_clauses.any?
184
+
185
+ sql += " ORDER BY m.created_at, m.id ASC"
186
+
187
+ db.prepare(sql).execute(*params).map do |row|
188
+ columns = ["id", "sender", "recipients", "subject", "size", "created_at"]
189
+ Hash[columns.zip(row)].tap do |message|
190
+ message["recipients"] &&= JSON.parse(message["recipients"])
191
+ end
192
+ end
193
+ end
194
+
121
195
  def message(id)
122
196
  @message_query ||= db.prepare "SELECT id, sender, recipients, subject, size, type, created_at FROM message WHERE id = ? LIMIT 1"
123
197
  row = @message_query.execute(id).next
@@ -354,6 +428,112 @@ module MailCatcher::Mail extend self
354
428
  end
355
429
  end
356
430
 
431
+ def extract_tokens(id, type:)
432
+ html_part = message_part_html(id)
433
+ plain_part = message_part_plain(id)
434
+
435
+ content = [html_part&.dig('body'), plain_part&.dig('body')].compact.join("\n")
436
+
437
+ case type
438
+ when 'link' then extract_magic_links(content)
439
+ when 'otp' then extract_otps(content)
440
+ when 'token' then extract_reset_tokens(content)
441
+ else []
442
+ end
443
+ end
444
+
445
+ def extract_all_links(id)
446
+ links = []
447
+
448
+ if html_part = message_part_html(id)
449
+ links += extract_links_from_html(html_part['body'])
450
+ end
451
+
452
+ if plain_part = message_part_plain(id)
453
+ links += extract_links_from_plain(plain_part['body'])
454
+ end
455
+
456
+ links
457
+ end
458
+
459
+ def parse_message_structured(id)
460
+ # Extract unsubscribe from List-Unsubscribe header
461
+ source = message_source(id)
462
+ unsubscribe_link = nil
463
+
464
+ if source
465
+ unsubscribe_header = source.lines.find { |l| l.match?(/^List-Unsubscribe:/i) }
466
+ unsubscribe_link = unsubscribe_header&.match(/<(https?:\/\/[^>]+)>/)&.[](1)
467
+ end
468
+
469
+ {
470
+ verification_url: extract_tokens(id, type: 'link').first&.dig(:value),
471
+ otp_code: extract_tokens(id, type: 'otp').first&.dig(:value),
472
+ reset_token: extract_tokens(id, type: 'token').first&.dig(:value),
473
+ unsubscribe_link: unsubscribe_link,
474
+ all_links: extract_all_links(id)
475
+ }
476
+ end
477
+
478
+ def accessibility_score(id)
479
+ html_part = message_part_html(id)
480
+ return { score: 0, error: 'No HTML part found' } unless html_part
481
+
482
+ doc = Nokogiri::HTML(html_part['body'])
483
+
484
+ alt_text_data = check_alt_text_detailed(doc)
485
+ semantic_data = check_semantic_html_detailed(doc)
486
+ links_data = check_links_detailed(doc)
487
+
488
+ scores = {
489
+ images_with_alt: alt_text_data[:score],
490
+ semantic_html: semantic_data[:score],
491
+ links_with_text: links_data[:score]
492
+ }
493
+
494
+ total_score = (scores.values.sum / scores.size.to_f).round
495
+
496
+ {
497
+ score: total_score,
498
+ breakdown: scores,
499
+ findings: {
500
+ images: alt_text_data[:findings],
501
+ semantic: semantic_data[:findings],
502
+ links: links_data[:findings]
503
+ },
504
+ recommendations: generate_recommendations(scores)
505
+ }
506
+ end
507
+
508
+ def forward_message(id)
509
+ return { error: 'SMTP not configured' } unless forward_smtp_configured?
510
+
511
+ message = message(id)
512
+ source = message_source(id)
513
+ recipients = JSON.parse(message['recipients'])
514
+
515
+ require 'net/smtp'
516
+
517
+ Net::SMTP.start(
518
+ MailCatcher.options[:forward_smtp_host],
519
+ MailCatcher.options[:forward_smtp_port] || 587,
520
+ 'localhost',
521
+ MailCatcher.options[:forward_smtp_user],
522
+ MailCatcher.options[:forward_smtp_password],
523
+ :plain
524
+ ) do |smtp|
525
+ smtp.send_message(source, message['sender'], recipients)
526
+ end
527
+
528
+ {
529
+ success: true,
530
+ forwarded_to: recipients,
531
+ forwarded_at: Time.now.utc.iso8601
532
+ }
533
+ rescue => e
534
+ { error: e.message }
535
+ end
536
+
357
537
  def message_encryption_data(id)
358
538
  source = message_source(id)
359
539
  return {} unless source
@@ -505,8 +685,208 @@ module MailCatcher::Mail extend self
505
685
  end
506
686
  end
507
687
 
688
+ def create_websocket_connection(session_id, client_ip)
689
+ @create_ws_connection_query ||= db.prepare(<<-SQL)
690
+ INSERT INTO websocket_connection (session_id, client_ip, opened_at, created_at, updated_at)
691
+ VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))
692
+ SQL
693
+ @create_ws_connection_query.execute(session_id, client_ip)
694
+ db.last_insert_row_id
695
+ end
696
+
697
+ def close_websocket_connection(session_id)
698
+ @close_ws_connection_query ||= db.prepare(<<-SQL)
699
+ UPDATE websocket_connection
700
+ SET closed_at = datetime('now'), updated_at = datetime('now')
701
+ WHERE session_id = ? AND closed_at IS NULL
702
+ SQL
703
+ @close_ws_connection_query.execute(session_id)
704
+ end
705
+
706
+ def record_websocket_ping(session_id)
707
+ @record_ping_query ||= db.prepare(<<-SQL)
708
+ UPDATE websocket_connection
709
+ SET last_ping_at = datetime('now'), ping_count = ping_count + 1, updated_at = datetime('now')
710
+ WHERE session_id = ? AND closed_at IS NULL
711
+ SQL
712
+ @record_ping_query.execute(session_id)
713
+ end
714
+
715
+ def record_websocket_pong(session_id)
716
+ @record_pong_query ||= db.prepare(<<-SQL)
717
+ UPDATE websocket_connection
718
+ SET last_pong_at = datetime('now'), pong_count = pong_count + 1, updated_at = datetime('now')
719
+ WHERE session_id = ? AND closed_at IS NULL
720
+ SQL
721
+ @record_pong_query.execute(session_id)
722
+ end
723
+
508
724
  private
509
725
 
726
+ def extract_magic_links(content)
727
+ # Extract URLs with common token parameters: token, verify, confirmation, magic
728
+ # Use non-capturing group (?:...) so scan returns the full match
729
+ pattern = %r{https?://[^\s<>]+[?&](?:token|verify|confirmation|magic)=[a-zA-Z0-9_\-.~%]+}i
730
+ content.scan(pattern).compact.map do |url|
731
+ # Extract surrounding context (50 chars before and after)
732
+ start_pos = content.index(url)
733
+ if start_pos
734
+ context_start = [0, start_pos - 50].max
735
+ context_end = [content.length, start_pos + url.length + 50].min
736
+ context = content[context_start...context_end].strip
737
+
738
+ {
739
+ type: 'magic_link',
740
+ value: url,
741
+ context: context
742
+ }
743
+ end
744
+ end.compact
745
+ end
746
+
747
+ def extract_otps(content)
748
+ # Extract 6-digit OTP codes, preferring those near keywords
749
+ pattern = /\b(\d{6})\b/
750
+ results = []
751
+
752
+ content.scan(pattern).each do |match|
753
+ otp = match[0]
754
+ start_pos = content.index(otp)
755
+ context_start = [0, start_pos - 50].max
756
+ context_end = [content.length, start_pos + otp.length + 50].min
757
+ context = content[context_start...context_end].strip
758
+
759
+ # Check if near keywords: code, otp, verification, confirm, pin
760
+ if context.match?(/code|otp|verification|confirm|pin/i)
761
+ results << {
762
+ type: 'otp',
763
+ value: otp,
764
+ context: context
765
+ }
766
+ end
767
+ end
768
+
769
+ results
770
+ end
771
+
772
+ def extract_reset_tokens(content)
773
+ # Extract reset token URLs
774
+ pattern = %r{https?://[^\s<>]*reset[^\s<>]*[?&]token=[a-zA-Z0-9_\-.~%]+}i
775
+ content.scan(pattern).map do |url|
776
+ start_pos = content.index(url)
777
+ context_start = [0, start_pos - 50].max
778
+ context_end = [content.length, start_pos + url.length + 50].min
779
+ context = content[context_start...context_end].strip
780
+
781
+ {
782
+ type: 'reset_token',
783
+ value: url,
784
+ context: context
785
+ }
786
+ end
787
+ end
788
+
789
+ def extract_links_from_html(html)
790
+ doc = Nokogiri::HTML(html)
791
+ doc.css('a[href]').map do |link|
792
+ href = link['href']
793
+ {
794
+ href: href,
795
+ text: link.text.strip,
796
+ is_verification: href.match?(/verify|confirm|token|magic|activate/i),
797
+ is_unsubscribe: href.match?(/unsubscribe|opt-out|remove/i)
798
+ }
799
+ end
800
+ end
801
+
802
+ def extract_links_from_plain(text)
803
+ text.scan(URI.regexp(['http', 'https'])).map do |match|
804
+ url = match[0]
805
+ {
806
+ href: url,
807
+ text: url,
808
+ is_verification: url.match?(/verify|confirm|token|magic|activate/i),
809
+ is_unsubscribe: url.match?(/unsubscribe|opt-out|remove/i)
810
+ }
811
+ end
812
+ end
813
+
814
+ def check_alt_text_detailed(doc)
815
+ images = doc.css('img')
816
+ return { score: 100, findings: { total: 0, with_alt: 0, without_alt: [] } } if images.empty?
817
+
818
+ with_alt = images.select { |img| img['alt'] && !img['alt'].strip.empty? }
819
+ without_alt = images.reject { |img| img['alt'] && !img['alt'].strip.empty? }
820
+
821
+ findings = {
822
+ total: images.size,
823
+ with_alt: with_alt.size,
824
+ without_alt: without_alt.map { |img| { src: img['src'], alt_missing: img['alt'].nil? } }
825
+ }
826
+
827
+ score = (with_alt.size.to_f / images.size * 100).round
828
+
829
+ { score: score, findings: findings }
830
+ end
831
+
832
+ def check_semantic_html_detailed(doc)
833
+ semantic_tags = doc.css('header, nav, main, article, section, footer, aside')
834
+ has_semantic = semantic_tags.any?
835
+
836
+ findings = {
837
+ has_semantic_tags: has_semantic,
838
+ found_tags: semantic_tags.map(&:name).uniq
839
+ }
840
+
841
+ score = has_semantic ? 100 : 50
842
+
843
+ { score: score, findings: findings }
844
+ end
845
+
846
+ def check_links_detailed(doc)
847
+ links = doc.css('a')
848
+ return { score: 100, findings: { total: 0, with_text: 0, without_text: [] } } if links.empty?
849
+
850
+ with_text = links.select { |a| !a.text.strip.empty? || (a['aria-label'] && !a['aria-label'].empty?) }
851
+ without_text = links.reject { |a| !a.text.strip.empty? || (a['aria-label'] && !a['aria-label'].empty?) }
852
+
853
+ findings = {
854
+ total: links.size,
855
+ with_text: with_text.size,
856
+ without_text: without_text.map { |a| { href: a['href'], text_empty: a.text.strip.empty? } }
857
+ }
858
+
859
+ score = (with_text.size.to_f / links.size * 100).round
860
+
861
+ { score: score, findings: findings }
862
+ end
863
+
864
+ def check_alt_text(doc)
865
+ images = doc.css('img')
866
+ return 100 if images.empty?
867
+
868
+ with_alt = images.select { |img| img['alt'] && !img['alt'].strip.empty? }
869
+ (with_alt.size.to_f / images.size * 100).round
870
+ end
871
+
872
+ def check_semantic_html(doc)
873
+ semantic_tags = doc.css('header, nav, main, article, section, footer, aside')
874
+ semantic_tags.any? ? 100 : 50
875
+ end
876
+
877
+ def generate_recommendations(scores)
878
+ recs = []
879
+ recs << "Add alt text to all images" if scores[:images_with_alt] < 100
880
+ recs << "Use semantic HTML tags (header, main, article, section)" if scores[:semantic_html] < 100
881
+ recs << "Ensure all links have descriptive text or aria-label" if scores[:links_with_text] < 100
882
+ recs
883
+ end
884
+
885
+ def forward_smtp_configured?
886
+ MailCatcher.options[:forward_smtp_host] &&
887
+ MailCatcher.options[:forward_smtp_port]
888
+ end
889
+
510
890
  def parse_authentication_results(auth_header)
511
891
  results = {
512
892
  dmarc: nil,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MailCatcher
4
- VERSION = '1.4.0'
4
+ VERSION = '1.5.2'
5
5
  end