Module: MailCatcher::Mail

Extended by:
Mail
Included in:
Mail
Defined in:
lib/mail_catcher/mail.rb

Instance Method Summary collapse

Instance Method Details

#accessibility_score(id) ⇒ Object



478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# File 'lib/mail_catcher/mail.rb', line 478

def accessibility_score(id)
  html_part = message_part_html(id)
  return { score: 0, error: 'No HTML part found' } unless html_part

  doc = Nokogiri::HTML(html_part['body'])

  alt_text_data = check_alt_text_detailed(doc)
  semantic_data = check_semantic_html_detailed(doc)
  links_data = check_links_detailed(doc)

  scores = {
    images_with_alt: alt_text_data[:score],
    semantic_html: semantic_data[:score],
    links_with_text: links_data[:score]
  }

  total_score = (scores.values.sum / scores.size.to_f).round

  {
    score: total_score,
    breakdown: scores,
    findings: {
      images: alt_text_data[:findings],
      semantic: semantic_data[:findings],
      links: links_data[:findings]
    },
    recommendations: generate_recommendations(scores)
  }
end

#add_message(message) ⇒ Object



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/mail_catcher/mail.rb', line 97

def add_message(message)
  @add_message_query ||= db.prepare("INSERT INTO message (sender, recipients, subject, source, type, size, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))")

  mail = Mail.new(message[:source])
  @add_message_query.execute(message[:sender], JSON.generate(message[:recipients]), mail.subject, message[:source], mail.mime_type || "text/plain", message[:source].length)
  message_id = db.last_insert_row_id
  parts = mail.all_parts
  parts = [mail] if parts.empty?
  parts.each do |part|
    body = part.body.to_s
    # Only parts have CIDs, not mail
    cid = part.cid if part.respond_to? :cid
    add_message_part(message_id, cid, part.mime_type || "text/plain", part.attachment? ? 1 : 0, part.filename, part.charset, body, body.length)
  end

  EventMachine.next_tick do
    message = MailCatcher::Mail.message message_id
    MailCatcher::Bus.push(type: "add", message: message)
  end

  message_id
end

#add_message_part(*args) ⇒ Object



120
121
122
123
# File 'lib/mail_catcher/mail.rb', line 120

def add_message_part(*args)
  @add_message_part_query ||= db.prepare "INSERT INTO message_part (message_id, cid, type, is_attachment, filename, charset, body, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))"
  @add_message_part_query.execute(*args)
end

#add_smtp_transcript(params) ⇒ Object



610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
# File 'lib/mail_catcher/mail.rb', line 610

def add_smtp_transcript(params)
  @add_smtp_transcript_query ||= db.prepare(<<-SQL)
    INSERT INTO smtp_transcript (
      message_id, session_id, client_ip, client_port,
      server_ip, server_port, tls_enabled, tls_protocol,
      tls_cipher, connection_started_at, connection_ended_at,
      entries, created_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
  SQL

  @add_smtp_transcript_query.execute(
    params[:message_id],
    params[:session_id],
    params[:client_ip],
    params[:client_port],
    params[:server_ip],
    params[:server_port],
    params[:tls_enabled],
    params[:tls_protocol],
    params[:tls_cipher],
    params[:connection_started_at]&.utc&.iso8601,
    params[:connection_ended_at]&.utc&.iso8601,
    JSON.generate(params[:entries])
  )
end

#all_transcript_entriesObject



670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
# File 'lib/mail_catcher/mail.rb', line 670

def all_transcript_entries
  @all_transcript_entries_query ||= db.prepare(<<-SQL)
    SELECT id, message_id, session_id, client_ip, client_port,
           server_ip, server_port, tls_enabled, tls_protocol,
           tls_cipher, connection_started_at, connection_ended_at,
           entries, created_at
    FROM smtp_transcript
    ORDER BY created_at DESC
  SQL

  @all_transcript_entries_query.execute.map do |row|
    result = Hash[@all_transcript_entries_query.columns.zip(row)]
    result['entries'] = JSON.parse(result['entries']) if result['entries']
    result['tls_enabled'] = result['tls_enabled'] == 1
    result
  end
end

#all_transcriptsObject



657
658
659
660
661
662
663
664
665
666
667
668
# File 'lib/mail_catcher/mail.rb', line 657

def all_transcripts
  @all_transcripts_query ||= db.prepare(<<-SQL)
    SELECT id, message_id, session_id, client_ip,
           connection_started_at, tls_enabled
    FROM smtp_transcript
    ORDER BY created_at DESC
  SQL

  @all_transcripts_query.execute.map do |row|
    Hash[@all_transcripts_query.columns.zip(row)]
  end
end

#close_websocket_connection(session_id) ⇒ Object



697
698
699
700
701
702
703
704
# File 'lib/mail_catcher/mail.rb', line 697

def close_websocket_connection(session_id)
  @close_ws_connection_query ||= db.prepare(<<-SQL)
    UPDATE websocket_connection
    SET closed_at = datetime('now'), updated_at = datetime('now')
    WHERE session_id = ? AND closed_at IS NULL
  SQL
  @close_ws_connection_query.execute(session_id)
end

#create_websocket_connection(session_id, client_ip) ⇒ Object



688
689
690
691
692
693
694
695
# File 'lib/mail_catcher/mail.rb', line 688

def create_websocket_connection(session_id, client_ip)
  @create_ws_connection_query ||= db.prepare(<<-SQL)
    INSERT INTO websocket_connection (session_id, client_ip, opened_at, created_at, updated_at)
    VALUES (?, ?, datetime('now'), datetime('now'), datetime('now'))
  SQL
  @create_ws_connection_query.execute(session_id, client_ip)
  db.last_insert_row_id
end

#dbObject



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/mail_catcher/mail.rb', line 12

def db
  @__db ||= begin
    db_path = determine_db_path
    SQLite3::Database.new(db_path, :type_translation => true).tap do |db|
      db.execute(<<-SQL)
        CREATE TABLE IF NOT EXISTS message (
          id INTEGER PRIMARY KEY ASC,
          sender TEXT,
          recipients TEXT,
          subject TEXT,
          source BLOB,
          size TEXT,
          type TEXT,
          created_at DATETIME DEFAULT CURRENT_DATETIME
        )
      SQL
      db.execute(<<-SQL)
        CREATE TABLE IF NOT EXISTS message_part (
          id INTEGER PRIMARY KEY ASC,
          message_id INTEGER NOT NULL,
          cid TEXT,
          type TEXT,
          is_attachment INTEGER,
          filename TEXT,
          charset TEXT,
          body BLOB,
          size INTEGER,
          created_at DATETIME DEFAULT CURRENT_DATETIME,
          FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
        )
      SQL
      db.execute(<<-SQL)
        CREATE TABLE IF NOT EXISTS smtp_transcript (
          id INTEGER PRIMARY KEY ASC,
          message_id INTEGER,
          session_id TEXT NOT NULL,
          client_ip TEXT,
          client_port INTEGER,
          server_ip TEXT,
          server_port INTEGER,
          tls_enabled INTEGER DEFAULT 0,
          tls_protocol TEXT,
          tls_cipher TEXT,
          connection_started_at DATETIME,
          connection_ended_at DATETIME,
          entries TEXT NOT NULL,
          created_at DATETIME DEFAULT CURRENT_DATETIME,
          FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE
        )
      SQL
      db.execute(<<-SQL)
        CREATE TABLE IF NOT EXISTS websocket_connection (
          id INTEGER PRIMARY KEY ASC,
          session_id TEXT NOT NULL,
          client_ip TEXT,
          opened_at DATETIME,
          closed_at DATETIME,
          last_ping_at DATETIME,
          last_pong_at DATETIME,
          ping_count INTEGER DEFAULT 0,
          pong_count INTEGER DEFAULT 0,
          created_at DATETIME DEFAULT CURRENT_DATETIME,
          updated_at DATETIME DEFAULT CURRENT_DATETIME
        )
      SQL
      db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_message_id ON smtp_transcript(message_id)")
      db.execute("CREATE INDEX IF NOT EXISTS idx_smtp_transcript_session_id ON smtp_transcript(session_id)")
      db.execute("CREATE INDEX IF NOT EXISTS idx_websocket_connection_session_id ON websocket_connection(session_id)")
      db.execute("PRAGMA foreign_keys = ON")
    end
  end
end

#delete!Object



400
401
402
403
404
405
406
407
408
409
410
# File 'lib/mail_catcher/mail.rb', line 400

def delete!
  @delete_all_messages_query ||= db.prepare "DELETE FROM message"
  @delete_all_transcripts_query ||= db.prepare "DELETE FROM smtp_transcript"

  @delete_all_messages_query.execute
  @delete_all_transcripts_query.execute

  EventMachine.next_tick do
    MailCatcher::Bus.push(type: "clear")
  end
end

#delete_message!(message_id) ⇒ Object



412
413
414
415
416
417
418
419
# File 'lib/mail_catcher/mail.rb', line 412

def delete_message!(message_id)
  @delete_messages_query ||= db.prepare "DELETE FROM message WHERE id = ?"
  @delete_messages_query.execute(message_id)

  EventMachine.next_tick do
    MailCatcher::Bus.push(type: "remove", id: message_id)
  end
end

#delete_older_messages!(count = ) ⇒ Object



421
422
423
424
425
426
427
428
429
# File 'lib/mail_catcher/mail.rb', line 421

def delete_older_messages!(count = MailCatcher.options[:messages_limit])
  return if count.nil?
  @older_messages_query ||= db.prepare "SELECT id FROM message WHERE id NOT IN (SELECT id FROM message ORDER BY created_at DESC LIMIT ?)"
  @older_messages_query.execute(count).map do |row|
    Hash[@older_messages_query.columns.zip(row)]
  end.each do |message|
    delete_message!(message["id"])
  end
end

#determine_db_pathObject



85
86
87
88
89
90
91
92
93
94
95
# File 'lib/mail_catcher/mail.rb', line 85

def determine_db_path
  if MailCatcher.options && MailCatcher.options[:persistence]
    # Use a persistent SQLite file in the user's home directory
    db_dir = File.expand_path('~/.mailcatcher')
    FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
    File.join(db_dir, 'mailcatcher.db')
  else
    # Use in-memory database
    ':memory:'
  end
end


445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/mail_catcher/mail.rb', line 445

def extract_all_links(id)
  links = []

  if html_part = message_part_html(id)
    links += extract_links_from_html(html_part['body'])
  end

  if plain_part = message_part_plain(id)
    links += extract_links_from_plain(plain_part['body'])
  end

  links
end

#extract_tokens(id, type:) ⇒ Object



431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'lib/mail_catcher/mail.rb', line 431

def extract_tokens(id, type:)
  html_part = message_part_html(id)
  plain_part = message_part_plain(id)

  content = [html_part&.dig('body'), plain_part&.dig('body')].compact.join("\n")

  case type
  when 'link' then extract_magic_links(content)
  when 'otp' then extract_otps(content)
  when 'token' then extract_reset_tokens(content)
  else []
  end
end

#forward_message(id) ⇒ Object



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
# File 'lib/mail_catcher/mail.rb', line 508

def forward_message(id)
  return { error: 'SMTP not configured' } unless forward_smtp_configured?

  message = message(id)
  source = message_source(id)
  recipients = JSON.parse(message['recipients'])

  require 'net/smtp'

  Net::SMTP.start(
    MailCatcher.options[:forward_smtp_host],
    MailCatcher.options[:forward_smtp_port] || 587,
    'localhost',
    MailCatcher.options[:forward_smtp_user],
    MailCatcher.options[:forward_smtp_password],
    :plain
  ) do |smtp|
    smtp.send_message(source, message['sender'], recipients)
  end

  {
    success: true,
    forwarded_to: recipients,
    forwarded_at: Time.now.utc.iso8601
  }
rescue => e
  { error: e.message }
end

#latest_created_atObject



125
126
127
128
# File 'lib/mail_catcher/mail.rb', line 125

def latest_created_at
  @latest_created_at_query ||= db.prepare "SELECT created_at FROM message ORDER BY created_at DESC LIMIT 1"
  @latest_created_at_query.execute.next
end

#message(id) ⇒ Object



195
196
197
198
199
200
201
# File 'lib/mail_catcher/mail.rb', line 195

def message(id)
  @message_query ||= db.prepare "SELECT id, sender, recipients, subject, size, type, created_at FROM message WHERE id = ? LIMIT 1"
  row = @message_query.execute(id).next
  row && Hash[@message_query.columns.zip(row)].tap do |message|
    message["recipients"] &&= JSON.parse(message["recipients"])
  end
end

#message_attachments(id) ⇒ Object



359
360
361
362
363
364
# File 'lib/mail_catcher/mail.rb', line 359

def message_attachments(id)
  @message_attachments_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? AND is_attachment = 1 ORDER BY filename ASC"
  @message_attachments_query.execute(id).map do |row|
    Hash[@message_attachments_query.columns.zip(row)]
  end
end

#message_authentication_results(id) ⇒ Object



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/mail_catcher/mail.rb', line 308

def message_authentication_results(id)
  source = message_source(id)
  return {} unless source

  auth_results = {
    dmarc: nil,
    dkim: nil,
    spf: nil
  }

  lines = source.lines
  lines.each_with_index do |line, index|
    break if line.strip.empty?

    # Authentication-Results header contains DMARC, DKIM, and SPF info
    if line.match?(/^authentication-results:\s*/i)
      # Extract the value and handle multi-line headers
      value = line.sub(/^authentication-results:\s*/i, '').strip

      # Continue reading continuation lines (lines starting with whitespace)
      next_index = index + 1
      while next_index < lines.length && lines[next_index].match?(/^\s+/)
        value += " " + lines[next_index].strip
        next_index += 1
      end

      auth_results = parse_authentication_results(value)
      break
    end
  end

  auth_results
end

#message_bimi_location(id) ⇒ Object



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/mail_catcher/mail.rb', line 209

def message_bimi_location(id)
  source = message_source(id)
  return nil unless source

  # Extract BIMI-Location header from email source
  # Headers are case-insensitive
  source.each_line do |line|
    # Stop at first blank line (end of headers)
    break if line.strip.empty?
    # Match BIMI-Location header (case-insensitive)
    if line.match?(/^bimi-location:\s*/i)
      # Extract the value and clean it up
      value = line.sub(/^bimi-location:\s*/i, '').strip
      return value unless value.empty?
    end
  end

  nil
end

#message_encryption_data(id) ⇒ Object



537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
# File 'lib/mail_catcher/mail.rb', line 537

def message_encryption_data(id)
  source = message_source(id)
  return {} unless source

  encryption_data = {
    smime: nil,
    pgp: nil
  }

  lines = source.lines
  lines.each_with_index do |line, index|
    break if line.strip.empty?

    # Check for S/MIME certificate headers
    if line.match?(/^x-certificate:/i)
      value = line.sub(/^x-certificate:\s*/i, '').strip
      # Handle multi-line headers
      next_index = index + 1
      while next_index < lines.length && lines[next_index].match?(/^\s+/)
        value += lines[next_index].strip
        next_index += 1
      end
      encryption_data[:smime] = { certificate: value } if value.present?
    end

    # Check for S/MIME signature headers
    if line.match?(/^x-smime-signature:/i)
      value = line.sub(/^x-smime-signature:\s*/i, '').strip
      # Handle multi-line headers
      next_index = index + 1
      while next_index < lines.length && lines[next_index].match?(/^\s+/)
        value += lines[next_index].strip
        next_index += 1
      end
      if encryption_data[:smime].nil?
        encryption_data[:smime] = { signature: value }
      else
        encryption_data[:smime][:signature] = value
      end
    end

    # Check for PGP key or signature headers
    if line.match?(/^x-pgp-key:/i)
      value = line.sub(/^x-pgp-key:\s*/i, '').strip
      # Handle multi-line headers
      next_index = index + 1
      while next_index < lines.length && lines[next_index].match?(/^\s+/)
        value += lines[next_index].strip
        next_index += 1
      end
      encryption_data[:pgp] = { key: value } if value.present?
    end

    # Check for PGP signature headers
    if line.match?(/^x-pgp-signature:/i)
      value = line.sub(/^x-pgp-signature:\s*/i, '').strip
      # Handle multi-line headers
      next_index = index + 1
      while next_index < lines.length && lines[next_index].match?(/^\s+/)
        value += lines[next_index].strip
        next_index += 1
      end
      if encryption_data[:pgp].nil?
        encryption_data[:pgp] = { signature: value }
      else
        encryption_data[:pgp][:signature] = value
      end
    end
  end

  encryption_data
end

#message_from(id) ⇒ Object



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/mail_catcher/mail.rb', line 250

def message_from(id)
  source = message_source(id)
  return nil unless source

  # Extract From header from email source
  source.each_line do |line|
    # Stop at first blank line (end of headers)
    break if line.strip.empty?
    # Match From header (case-insensitive)
    if line.match?(/^from:\s*/i)
      # Extract the value and handle multi-line headers
      value = line.sub(/^from:\s*/i, '').strip

      # Continue reading continuation lines (lines starting with whitespace)
      lines = source.lines
      line_index = lines.index { |l| l.match?(/^from:\s*/i) }
      next_index = line_index + 1 if line_index
      while next_index && next_index < lines.length && lines[next_index].match?(/^\s+/)
        value += " " + lines[next_index].strip
        next_index += 1
      end

      return value unless value.empty?
    end
  end

  nil
end

#message_has_html?(id) ⇒ Boolean

Returns:

  • (Boolean)


342
343
344
345
# File 'lib/mail_catcher/mail.rb', line 342

def message_has_html?(id)
  @message_has_html_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type IN ('application/xhtml+xml', 'text/html') LIMIT 1"
  (!!@message_has_html_query.execute(id).next) || ["text/html", "application/xhtml+xml"].include?(message(id)["type"])
end

#message_has_plain?(id) ⇒ Boolean

Returns:

  • (Boolean)


347
348
349
350
# File 'lib/mail_catcher/mail.rb', line 347

def message_has_plain?(id)
  @message_has_plain_query ||= db.prepare "SELECT 1 FROM message_part WHERE message_id = ? AND is_attachment = 0 AND type = 'text/plain' LIMIT 1"
  (!!@message_has_plain_query.execute(id).next) || message(id)["type"] == "text/plain"
end

#message_part(message_id, part_id) ⇒ Object



366
367
368
369
370
# File 'lib/mail_catcher/mail.rb', line 366

def message_part(message_id, part_id)
  @message_part_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND id = ? LIMIT 1"
  row = @message_part_query.execute(message_id, part_id).next
  row && Hash[@message_part_query.columns.zip(row)]
end

#message_part_cid(message_id, cid) ⇒ Object



391
392
393
394
395
396
397
398
# File 'lib/mail_catcher/mail.rb', line 391

def message_part_cid(message_id, cid)
  @message_part_cid_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ?"
  @message_part_cid_query.execute(message_id).map do |row|
    Hash[@message_part_cid_query.columns.zip(row)]
  end.find do |part|
    part["cid"] == cid
  end
end

#message_part_html(message_id) ⇒ Object



378
379
380
381
382
383
384
385
# File 'lib/mail_catcher/mail.rb', line 378

def message_part_html(message_id)
  part = message_part_type(message_id, "text/html")
  part ||= message_part_type(message_id, "application/xhtml+xml")
  part ||= begin
    message = message(message_id)
    message if message and ["text/html", "application/xhtml+xml"].include? message["type"]
  end
end

#message_part_plain(message_id) ⇒ Object



387
388
389
# File 'lib/mail_catcher/mail.rb', line 387

def message_part_plain(message_id)
  message_part_type message_id, "text/plain"
end

#message_part_type(message_id, part_type) ⇒ Object



372
373
374
375
376
# File 'lib/mail_catcher/mail.rb', line 372

def message_part_type(message_id, part_type)
  @message_part_type_query ||= db.prepare "SELECT * FROM message_part WHERE message_id = ? AND type = ? AND is_attachment = 0 LIMIT 1"
  row = @message_part_type_query.execute(message_id, part_type).next
  row && Hash[@message_part_type_query.columns.zip(row)]
end

#message_parts(id) ⇒ Object



352
353
354
355
356
357
# File 'lib/mail_catcher/mail.rb', line 352

def message_parts(id)
  @message_parts_query ||= db.prepare "SELECT cid, type, filename, size FROM message_part WHERE message_id = ? ORDER BY filename ASC"
  @message_parts_query.execute(id).map do |row|
    Hash[@message_parts_query.columns.zip(row)]
  end
end

#message_preview_text(id) ⇒ Object



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/mail_catcher/mail.rb', line 229

def message_preview_text(id)
  source = message_source(id)
  return nil unless source

  # Extract Preview-Text header from email source (tier 1 of preview text extraction)
  # This is a de facto standard header (not formal RFC) used by email clients
  # to display preview/preheader text in the inbox preview pane
  source.each_line do |line|
    # Stop at first blank line (end of headers)
    break if line.strip.empty?
    # Match Preview-Text header (case-insensitive)
    if line.match?(/^preview-text:\s*/i)
      # Extract the value and clean it up
      value = line.sub(/^preview-text:\s*/i, '').strip
      return value unless value.empty?
    end
  end

  nil
end

#message_source(id) ⇒ Object



203
204
205
206
207
# File 'lib/mail_catcher/mail.rb', line 203

def message_source(id)
  @message_source_query ||= db.prepare "SELECT source FROM message WHERE id = ? LIMIT 1"
  row = @message_source_query.execute(id).next
  row && row.first
end

#message_to(id) ⇒ Object



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/mail_catcher/mail.rb', line 279

def message_to(id)
  source = message_source(id)
  return nil unless source

  # Extract To header from email source
  source.each_line do |line|
    # Stop at first blank line (end of headers)
    break if line.strip.empty?
    # Match To header (case-insensitive)
    if line.match?(/^to:\s*/i)
      # Extract the value and handle multi-line headers
      value = line.sub(/^to:\s*/i, '').strip

      # Continue reading continuation lines (lines starting with whitespace)
      lines = source.lines
      line_index = lines.index { |l| l.match?(/^to:\s*/i) }
      next_index = line_index + 1 if line_index
      while next_index && next_index < lines.length && lines[next_index].match?(/^\s+/)
        value += " " + lines[next_index].strip
        next_index += 1
      end

      return value unless value.empty?
    end
  end

  nil
end

#message_transcript(message_id) ⇒ Object



636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
# File 'lib/mail_catcher/mail.rb', line 636

def message_transcript(message_id)
  @message_transcript_query ||= db.prepare(<<-SQL)
    SELECT id, message_id, session_id, client_ip, client_port,
           server_ip, server_port, tls_enabled, tls_protocol,
           tls_cipher, connection_started_at, connection_ended_at,
           entries, created_at
    FROM smtp_transcript
    WHERE message_id = ?
    ORDER BY created_at DESC
    LIMIT 1
  SQL

  row = @message_transcript_query.execute(message_id).next
  return nil unless row

  result = Hash[@message_transcript_query.columns.zip(row)]
  result['entries'] = JSON.parse(result['entries']) if result['entries']
  result['tls_enabled'] = result['tls_enabled'] == 1
  result
end

#messagesObject



130
131
132
133
134
135
136
137
# File 'lib/mail_catcher/mail.rb', line 130

def messages
  @messages_query ||= db.prepare "SELECT id, sender, recipients, subject, size, created_at FROM message ORDER BY created_at, id ASC"
  @messages_query.execute.map do |row|
    Hash[@messages_query.columns.zip(row)].tap do |message|
      message["recipients"] &&= JSON.parse(message["recipients"])
    end
  end
end

#parse_message_structured(id) ⇒ Object



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/mail_catcher/mail.rb', line 459

def parse_message_structured(id)
  # Extract unsubscribe from List-Unsubscribe header
  source = message_source(id)
  unsubscribe_link = nil

  if source
    unsubscribe_header = source.lines.find { |l| l.match?(/^List-Unsubscribe:/i) }
    unsubscribe_link = unsubscribe_header&.match(/<(https?:\/\/[^>]+)>/)&.[](1)
  end

  {
    verification_url: extract_tokens(id, type: 'link').first&.dig(:value),
    otp_code: extract_tokens(id, type: 'otp').first&.dig(:value),
    reset_token: extract_tokens(id, type: 'token').first&.dig(:value),
    unsubscribe_link: unsubscribe_link,
    all_links: extract_all_links(id)
  }
end

#record_websocket_ping(session_id) ⇒ Object



706
707
708
709
710
711
712
713
# File 'lib/mail_catcher/mail.rb', line 706

def record_websocket_ping(session_id)
  @record_ping_query ||= db.prepare(<<-SQL)
    UPDATE websocket_connection
    SET last_ping_at = datetime('now'), ping_count = ping_count + 1, updated_at = datetime('now')
    WHERE session_id = ? AND closed_at IS NULL
  SQL
  @record_ping_query.execute(session_id)
end

#record_websocket_pong(session_id) ⇒ Object



715
716
717
718
719
720
721
722
# File 'lib/mail_catcher/mail.rb', line 715

def record_websocket_pong(session_id)
  @record_pong_query ||= db.prepare(<<-SQL)
    UPDATE websocket_connection
    SET last_pong_at = datetime('now'), pong_count = pong_count + 1, updated_at = datetime('now')
    WHERE session_id = ? AND closed_at IS NULL
  SQL
  @record_pong_query.execute(session_id)
end

#search_messages(query: nil, has_attachments: nil, from_date: nil, to_date: nil) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/mail_catcher/mail.rb', line 139

def search_messages(query: nil, has_attachments: nil, from_date: nil, to_date: nil)
  # Build dynamic SQL query with filters
  sql = "SELECT DISTINCT m.id, m.sender, m.recipients, m.subject, m.size, m.created_at FROM message m"
  params = []
  where_clauses = []

  # Determine if we need to join with message_part table
  needs_join = (query && !query.strip.empty?) || has_attachments.is_a?(TrueClass)

  # Join with message_part table if needed
  if needs_join
    sql += " LEFT JOIN message_part mp ON m.id = mp.message_id"
  end

  # Add search filters - search across subject, sender, and recipients (always available)
  # Also search body if we have the JOIN
  if query && !query.strip.empty?
    q = "%#{query}%"
    if needs_join
      where_clauses << "(m.subject LIKE ? OR m.sender LIKE ? OR m.recipients LIKE ? OR mp.body LIKE ?)"
      params.concat([q, q, q, q])
    else
      where_clauses << "(m.subject LIKE ? OR m.sender LIKE ? OR m.recipients LIKE ?)"
      params.concat([q, q, q])
    end
  end

  # Add attachment filter
  if has_attachments.is_a?(TrueClass)
    where_clauses << "(mp.is_attachment = 1)"
  end

  # Add date range filters
  if from_date
    where_clauses << "(m.created_at >= ?)"
    params << from_date
  end

  if to_date
    where_clauses << "(m.created_at <= ?)"
    params << to_date
  end

  # Combine where clauses
  sql += " WHERE #{where_clauses.join(' AND ')}" if where_clauses.any?

  sql += " ORDER BY m.created_at, m.id ASC"

  db.prepare(sql).execute(*params).map do |row|
    columns = ["id", "sender", "recipients", "subject", "size", "created_at"]
    Hash[columns.zip(row)].tap do |message|
      message["recipients"] &&= JSON.parse(message["recipients"])
    end
  end
end