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.
- 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 +380 -0
- data/lib/mail_catcher/version.rb +1 -1
- data/lib/mail_catcher/web/application.rb +483 -3
- data/lib/mail_catcher.rb +49 -8
- data/public/assets/atom-one-light.min.css +1 -1
- data/public/assets/favcount.js +102 -0
- data/public/assets/highlight.min.js +370 -401
- data/public/assets/jquery.min.js +2 -0
- data/public/assets/jquery.min.map +1 -0
- data/public/assets/mailcatcher-ui.js +3 -0
- data/public/assets/mailcatcher.css +197 -0
- data/public/assets/mailcatcher.js +1361 -22
- data/public/assets/popper.min.js +6 -0
- data/public/assets/popper.min.js.map +1 -0
- data/public/assets/tippy-light.min.css +1 -0
- data/public/assets/tippy.min.js +2 -0
- data/views/index.erb +49 -7
- data/views/server_info.erb +20 -8
- metadata +25 -21
|
@@ -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
|
|
@@ -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,
|
data/lib/mail_catcher/version.rb
CHANGED