mailcatcher-ng 1.4.6 → 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.
- checksums.yaml +4 -4
- data/README.md +66 -0
- data/lib/mail_catcher/integrations/mcp_server.rb +187 -0
- data/lib/mail_catcher/integrations/mcp_tools.rb +370 -0
- data/lib/mail_catcher/integrations.rb +63 -0
- data/lib/mail_catcher/mail.rb +328 -0
- data/lib/mail_catcher/version.rb +1 -1
- data/lib/mail_catcher/web/application.rb +442 -0
- data/lib/mail_catcher.rb +42 -1
- data/public/assets/mailcatcher.css +154 -0
- data/public/assets/mailcatcher.js +176 -0
- data/views/index.erb +29 -0
- metadata +18 -1
|
@@ -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
|
data/lib/mail_catcher/mail.rb
CHANGED
|
@@ -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
|
|
@@ -134,6 +136,62 @@ module MailCatcher::Mail extend self
|
|
|
134
136
|
end
|
|
135
137
|
end
|
|
136
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
|
+
|
|
137
195
|
def message(id)
|
|
138
196
|
@message_query ||= db.prepare "SELECT id, sender, recipients, subject, size, type, created_at FROM message WHERE id = ? LIMIT 1"
|
|
139
197
|
row = @message_query.execute(id).next
|
|
@@ -370,6 +428,112 @@ module MailCatcher::Mail extend self
|
|
|
370
428
|
end
|
|
371
429
|
end
|
|
372
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
|
+
|
|
373
537
|
def message_encryption_data(id)
|
|
374
538
|
source = message_source(id)
|
|
375
539
|
return {} unless source
|
|
@@ -559,6 +723,170 @@ module MailCatcher::Mail extend self
|
|
|
559
723
|
|
|
560
724
|
private
|
|
561
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
|
+
|
|
562
890
|
def parse_authentication_results(auth_header)
|
|
563
891
|
results = {
|
|
564
892
|
dmarc: nil,
|
data/lib/mail_catcher/version.rb
CHANGED