mail_dude 0.1.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +237 -0
  5. data/app/assets/images/mail_dude/icon.png +0 -0
  6. data/app/assets/stylesheets/mail_dude/application.css +180 -0
  7. data/app/channels/mail_dude/messages_channel.rb +18 -0
  8. data/app/controllers/mail_dude/application_controller.rb +25 -0
  9. data/app/controllers/mail_dude/attachments_controller.rb +26 -0
  10. data/app/controllers/mail_dude/messages_controller.rb +72 -0
  11. data/app/helpers/mail_dude/application_helper.rb +13 -0
  12. data/app/models/mail_dude/application_record.rb +7 -0
  13. data/app/models/mail_dude/stored_email.rb +7 -0
  14. data/app/views/layouts/mail_dude/application.html.erb +134 -0
  15. data/app/views/mail_dude/messages/_empty.html.erb +4 -0
  16. data/app/views/mail_dude/messages/_error.html.erb +5 -0
  17. data/app/views/mail_dude/messages/_list.html.erb +44 -0
  18. data/app/views/mail_dude/messages/_message_pane.html.erb +30 -0
  19. data/app/views/mail_dude/messages/_metadata.html.erb +33 -0
  20. data/app/views/mail_dude/messages/_tabs.html.erb +14 -0
  21. data/app/views/mail_dude/messages/error.html.erb +4 -0
  22. data/app/views/mail_dude/messages/index.html.erb +17 -0
  23. data/app/views/mail_dude/messages/show.html.erb +2 -0
  24. data/config/routes.rb +19 -0
  25. data/db/migrate/20260506170200_create_mail_dude_stored_emails.rb +36 -0
  26. data/lib/generators/mail_dude/install_generator.rb +49 -0
  27. data/lib/generators/mail_dude/templates/create_mail_dude_stored_emails.tt +37 -0
  28. data/lib/generators/mail_dude/templates/initializer.tt +17 -0
  29. data/lib/mail_dude/attachment_locator.rb +111 -0
  30. data/lib/mail_dude/configuration.rb +102 -0
  31. data/lib/mail_dude/dashboard.rb +6 -0
  32. data/lib/mail_dude/delivery_method.rb +46 -0
  33. data/lib/mail_dude/engine.rb +24 -0
  34. data/lib/mail_dude/errors.rb +11 -0
  35. data/lib/mail_dude/html_body_renderer.rb +49 -0
  36. data/lib/mail_dude/mailer_metadata_headers.rb +22 -0
  37. data/lib/mail_dude/message_broadcast.rb +50 -0
  38. data/lib/mail_dude/message_presenter.rb +136 -0
  39. data/lib/mail_dude/message_record.rb +17 -0
  40. data/lib/mail_dude/message_serializer.rb +117 -0
  41. data/lib/mail_dude/pagination.rb +35 -0
  42. data/lib/mail_dude/stores/base.rb +106 -0
  43. data/lib/mail_dude/stores/database_store.rb +93 -0
  44. data/lib/mail_dude/stores/file_store.rb +126 -0
  45. data/lib/mail_dude/stores/memory_store.rb +53 -0
  46. data/lib/mail_dude/version.rb +5 -0
  47. data/lib/mail_dude.rb +80 -0
  48. data/lib/tasks/mail_dude.rake +25 -0
  49. metadata +224 -0
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class Dashboard
5
+ end
6
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class DeliveryMethod
5
+ attr_reader :settings
6
+
7
+ def initialize(settings = {})
8
+ @settings = settings || {}
9
+ end
10
+
11
+ def deliver!(mail)
12
+ raise_disabled! unless MailDude.enabled?
13
+
14
+ raw_source = mail.to_s
15
+ validate_size!(raw_source)
16
+ record = MailDude.store.write(mail)
17
+ MessageBroadcast.broadcast(record)
18
+ MailDude.store.prune
19
+ log_capture(record)
20
+ record
21
+ end
22
+
23
+ private
24
+
25
+ def log_capture(record)
26
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
27
+
28
+ Rails.logger.debug { "Captured email #{record.id} subject=#{record.metadata['subject'].to_s.inspect}" }
29
+ end
30
+
31
+ def raise_disabled!
32
+ env = MailDude.rails_environment
33
+ message = "MailDude is disabled in #{env}. Configure enabled_environments or set allow_production only " \
34
+ 'after reviewing the risks.'
35
+ raise DisabledEnvironmentError, message
36
+ end
37
+
38
+ def validate_size!(raw_source)
39
+ limit = MailDude.configuration.max_message_size
40
+ return if limit.nil? || raw_source.bytesize <= limit
41
+
42
+ raise MessageTooLargeError,
43
+ "Email is #{raw_source.bytesize} bytes, exceeding MailDude max_message_size of #{limit} bytes."
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class Engine < ::Rails::Engine
5
+ ASSET_PRECOMPILE = ['mail_dude/application.css', 'mail_dude/icon.png'].freeze
6
+
7
+ isolate_namespace MailDude
8
+
9
+ initializer 'mail_dude.action_mailer' do
10
+ ActiveSupport.on_load(:action_mailer) do
11
+ add_delivery_method :mail_dude, MailDude::DeliveryMethod
12
+ after_action { MailDude::MailerMetadataHeaders.apply(self) }
13
+ end
14
+ end
15
+
16
+ initializer 'mail_dude.assets' do |app|
17
+ app.config.assets.precompile.concat(ASSET_PRECOMPILE) if app.config.respond_to?(:assets)
18
+ end
19
+
20
+ rake_tasks do
21
+ load 'tasks/mail_dude.rake'
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class Error < StandardError; end
5
+ class DisabledEnvironmentError < Error; end
6
+ class InvalidConfigurationError < Error; end
7
+ class StorageError < Error; end
8
+ class MessageNotFoundError < Error; end
9
+ class AttachmentNotFoundError < Error; end
10
+ class MessageTooLargeError < Error; end
11
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module MailDude
6
+ class HtmlBodyRenderer
7
+ PLACEHOLDER = '<p>This message does not include an HTML body.</p>'
8
+
9
+ def initialize(presenter, attachment_url:)
10
+ @attachment_url = attachment_url
11
+ @presenter = presenter
12
+ end
13
+
14
+ def render
15
+ html = presenter.html_body
16
+ return PLACEHOLDER if html.blank?
17
+
18
+ fragment = Nokogiri::HTML::DocumentFragment.parse(html)
19
+ rewrite_cid_images(fragment)
20
+ rewrite_links(fragment)
21
+ fragment.to_html
22
+ rescue StandardError
23
+ html.to_s
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :attachment_url, :presenter
29
+
30
+ def locator
31
+ @locator ||= AttachmentLocator.new(presenter.record)
32
+ end
33
+
34
+ def rewrite_cid_images(fragment)
35
+ fragment.css("[src^='cid:']").each do |node|
36
+ attachment = locator.find_inline_by_cid(node['src'])
37
+ node['src'] = attachment_url.call(attachment.id, inline: true) if attachment
38
+ end
39
+ end
40
+
41
+ def rewrite_links(fragment)
42
+ fragment.css('a[href]').each do |node|
43
+ node['target'] = '_blank'
44
+ rel_values = node['rel'].to_s.split | %w[noopener noreferrer]
45
+ node['rel'] = rel_values.join(' ')
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ module MailerMetadataHeaders
5
+ module_function
6
+
7
+ def apply(mailer)
8
+ return unless MailDude.configuration.capture_mailer_metadata_headers
9
+ return unless MailDude.enabled?
10
+ return unless mail_dude_delivery?(mailer)
11
+
12
+ mailer.message[MessageSerializer::INTERNAL_MAILER_HEADER] = mailer.class.name
13
+ mailer.message[MessageSerializer::INTERNAL_ACTION_HEADER] = mailer.action_name
14
+ end
15
+
16
+ def mail_dude_delivery?(mailer)
17
+ mailer.message.delivery_method.is_a?(DeliveryMethod) ||
18
+ mailer.class.delivery_method == :mail_dude ||
19
+ ActionMailer::Base.delivery_method == :mail_dude
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class MessageBroadcast
5
+ def self.broadcast(record)
6
+ new(record).broadcast
7
+ end
8
+
9
+ def initialize(record)
10
+ @record = record
11
+ end
12
+
13
+ def broadcast
14
+ return false unless MailDude.configuration.live_updates
15
+ return false unless action_cable_server
16
+
17
+ action_cable_server.broadcast(MailDude.configuration.live_update_stream_name, payload)
18
+ true
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :record
24
+
25
+ def action_cable_server
26
+ ActionCable.server if defined?(ActionCable)
27
+ end
28
+
29
+ def payload
30
+ presenter = MessagePresenter.new(record)
31
+ {
32
+ event: 'message_created',
33
+ id: record.id,
34
+ **list_metadata(presenter)
35
+ }
36
+ end
37
+
38
+ def list_metadata(presenter)
39
+ {
40
+ subject: presenter.subject_label,
41
+ sender: presenter.sender_summary,
42
+ recipients: presenter.recipient_summary,
43
+ captured_at: presenter.captured_at_label,
44
+ attachments_count: presenter.attachments.length,
45
+ attachment_count_label: presenter.attachment_count_label,
46
+ mailer_label: presenter.mailer_label
47
+ }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class MessagePresenter
5
+ FALLBACK_DECODE_ERROR = 'Unable to decode this message part.'
6
+
7
+ attr_reader :record
8
+
9
+ delegate :id, to: :record
10
+
11
+ def initialize(record)
12
+ @record = record
13
+ end
14
+
15
+ def subject
16
+ metadata_value('subject').to_s
17
+ end
18
+
19
+ def subject_label
20
+ subject.strip.presence || '(no subject)'
21
+ end
22
+
23
+ def captured_at
24
+ Time.iso8601(metadata_value('captured_at').to_s)
25
+ rescue ArgumentError, TypeError
26
+ nil
27
+ end
28
+
29
+ def captured_at_label
30
+ captured_at ? captured_at.strftime('%Y-%m-%d %H:%M:%S UTC') : '(unknown time)'
31
+ end
32
+
33
+ def from = metadata_array('from')
34
+ def sender = metadata_array('sender')
35
+ def to = metadata_array('to')
36
+ def cc = metadata_array('cc')
37
+ def bcc = metadata_array('bcc')
38
+ def reply_to = metadata_array('reply_to')
39
+ def message_id = metadata_value('message_id')
40
+ def mailer = metadata_value('mailer')
41
+ def mailer_action = metadata_value('mailer_action')
42
+ def content_type = metadata_value('content_type')
43
+ def size_bytes = metadata_value('size_bytes').to_i
44
+ def raw_source = record.raw_source.to_s
45
+ def attachments = metadata_array('attachments')
46
+
47
+ def mailer_label
48
+ return '(unknown mailer)' if mailer.blank? && mailer_action.blank?
49
+ return mailer if mailer_action.blank?
50
+ return "##{mailer_action}" if mailer.blank?
51
+
52
+ "#{mailer}##{mailer_action}"
53
+ end
54
+
55
+ def size_label
56
+ bytes = size_bytes
57
+ return "#{bytes} B" if bytes < 1.kilobyte
58
+ return "#{format('%.1f', bytes / 1.kilobyte.to_f)} KB" if bytes < 1.megabyte
59
+
60
+ "#{format('%.1f', bytes / 1.megabyte.to_f)} MB"
61
+ end
62
+
63
+ def mail
64
+ @mail ||= raw_source.present? ? Mail.read_from_string(raw_source) : nil
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ def html_body
70
+ decoded_body_for('text/html')
71
+ end
72
+
73
+ def text_body
74
+ decoded_body_for('text/plain')
75
+ end
76
+
77
+ def raw_headers
78
+ return '' if raw_source.blank?
79
+
80
+ raw_source.split(/\r?\n\r?\n/, 2).first.to_s
81
+ end
82
+
83
+ def has_attachments?
84
+ attachments.any?
85
+ end
86
+
87
+ def attachment_count_label
88
+ count = attachments.length
89
+ "#{count} #{'attachment'.pluralize(count)}"
90
+ end
91
+
92
+ def recipient_summary
93
+ to.first.presence || cc.first.presence || bcc.first.presence || '(no recipients)'
94
+ end
95
+
96
+ def sender_summary
97
+ from.first.presence || sender.first.presence || '(unknown sender)'
98
+ end
99
+
100
+ def list_preview
101
+ preview = text_body.presence || ActionView::Base.full_sanitizer.sanitize(html_body.to_s)
102
+ preview.to_s.squish.truncate(140)
103
+ end
104
+
105
+ private
106
+
107
+ def decoded_body_for(mime_type)
108
+ part = body_part(mime_type)
109
+ return nil unless part
110
+
111
+ decode_part(part)
112
+ end
113
+
114
+ def body_part(mime_type)
115
+ return nil unless mail
116
+ return mail if !mail.multipart? && mail.mime_type == mime_type
117
+
118
+ mail.all_parts.find { |part| part.mime_type == mime_type && !part.attachment? }
119
+ end
120
+
121
+ def decode_part(part)
122
+ decoded = part.decoded
123
+ decoded.to_s.encode('UTF-8', invalid: :replace, undef: :replace)
124
+ rescue StandardError
125
+ FALLBACK_DECODE_ERROR
126
+ end
127
+
128
+ def metadata_array(key)
129
+ Array(metadata_value(key)).compact
130
+ end
131
+
132
+ def metadata_value(key)
133
+ record.metadata[key]
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class MessageRecord
5
+ attr_reader :id, :metadata, :raw_source
6
+
7
+ def initialize(id:, metadata:, raw_source: nil)
8
+ @id = id
9
+ @metadata = metadata.stringify_keys
10
+ @raw_source = raw_source
11
+ end
12
+
13
+ def full?
14
+ raw_source.present?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class MessageSerializer
5
+ INTERNAL_MAILER_HEADER = 'X-Mail-Dude-Mailer'
6
+ INTERNAL_ACTION_HEADER = 'X-Mail-Dude-Action'
7
+
8
+ attr_reader :captured_at, :id, :mail, :raw_source
9
+
10
+ def initialize(mail, id:, captured_at:, raw_source: nil)
11
+ @mail = mail
12
+ @id = id
13
+ @captured_at = captured_at.utc
14
+ @raw_source = raw_source || mail.to_s
15
+ end
16
+
17
+ def metadata
18
+ {
19
+ 'id' => id,
20
+ 'captured_at' => captured_at.iso8601(6),
21
+ 'subject' => decoded_subject,
22
+ 'from' => addresses(:from),
23
+ 'sender' => addresses(:sender),
24
+ 'to' => addresses(:to),
25
+ 'cc' => addresses(:cc),
26
+ 'bcc' => addresses(:bcc),
27
+ 'reply_to' => addresses(:reply_to),
28
+ 'date' => date_value,
29
+ 'message_id' => header_value(:message_id),
30
+ 'in_reply_to' => header_value(:in_reply_to),
31
+ 'references' => references,
32
+ 'content_type' => content_type,
33
+ 'mime_version' => header_value(:mime_version),
34
+ 'mailer' => internal_header(INTERNAL_MAILER_HEADER),
35
+ 'mailer_action' => internal_header(INTERNAL_ACTION_HEADER),
36
+ 'has_html' => part_present?('text/html'),
37
+ 'has_text' => part_present?('text/plain'),
38
+ 'has_attachments' => attachments.any?,
39
+ 'attachments_count' => attachments.length,
40
+ 'attachments' => attachments,
41
+ 'size_bytes' => raw_source.bytesize
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def addresses(field_name)
48
+ field = mail[field_name]
49
+ return [] unless field
50
+
51
+ field.addrs.map { |address| format_address(address) }.compact
52
+ rescue StandardError
53
+ fallback = field.to_s.strip
54
+ fallback.present? ? [fallback] : []
55
+ end
56
+
57
+ def attachments
58
+ @attachments ||= AttachmentLocator.new(mail).attachments.map(&:metadata)
59
+ end
60
+
61
+ def content_type
62
+ mail.mime_type.presence || mail.content_type.to_s.split(';').first.presence
63
+ end
64
+
65
+ def date_value
66
+ date = mail.date
67
+ return nil unless date
68
+
69
+ date.to_time.utc.iso8601
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
74
+ def decoded_subject
75
+ mail.subject
76
+ rescue StandardError
77
+ mail[:subject].to_s
78
+ end
79
+
80
+ def format_address(address)
81
+ display = address.display_name.to_s
82
+ email = address.address.to_s
83
+ return email if display.blank?
84
+ return display if email.blank?
85
+
86
+ "#{display} <#{email}>"
87
+ end
88
+
89
+ def header_value(field_name)
90
+ value = mail.public_send(field_name)
91
+ value.respond_to?(:value) ? value.value : value
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def internal_header(name)
97
+ mail[name]&.decoded
98
+ rescue StandardError
99
+ nil
100
+ end
101
+
102
+ def part_present?(mime_type)
103
+ if mail.multipart?
104
+ mail.all_parts.any? { |part| part.mime_type == mime_type && !part.attachment? }
105
+ else
106
+ mail.mime_type == mime_type
107
+ end
108
+ end
109
+
110
+ def references
111
+ raw = mail.references
112
+ Array(raw).flat_map { |value| value.to_s.split(/\s+/) }.compact_blank
113
+ rescue StandardError
114
+ []
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class Page
5
+ attr_reader :page, :per_page, :records, :total_count
6
+
7
+ def initialize(records:, page:, per_page:, total_count:)
8
+ @records = records
9
+ @page = normalize_positive(page, 1)
10
+ @per_page = normalize_positive(per_page, MailDude.configuration.default_per_page)
11
+ @total_count = total_count.to_i
12
+ end
13
+
14
+ def total_pages
15
+ [(total_count.to_f / per_page).ceil, 1].max
16
+ end
17
+
18
+ def next_page
19
+ page < total_pages ? page + 1 : nil
20
+ end
21
+
22
+ def previous_page
23
+ page > 1 ? page - 1 : nil
24
+ end
25
+
26
+ private
27
+
28
+ def normalize_positive(value, fallback)
29
+ integer = Integer(value)
30
+ integer.positive? ? integer : fallback
31
+ rescue ArgumentError, TypeError
32
+ fallback
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module MailDude
6
+ module Stores
7
+ class Base
8
+ ID_PATTERN = /\A\d{8}T\d{12}Z-[a-f0-9]{16}\z/
9
+
10
+ def write(_mail)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def list(page: 1, per_page: MailDude.configuration.default_per_page, query: nil)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def find(_id)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def delete(_id)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def clear
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def prune(max_messages: MailDude.configuration.max_messages,
31
+ retention_period: MailDude.configuration.retention_period)
32
+ raise NotImplementedError
33
+ end
34
+
35
+ private
36
+
37
+ def build_record(mail, id: generate_id, captured_at: Time.now.utc)
38
+ raw_source = mail.to_s
39
+ metadata = MessageSerializer.new(mail, id: id, captured_at: captured_at, raw_source: raw_source).metadata
40
+ MessageRecord.new(id: id, metadata: metadata, raw_source: raw_source)
41
+ end
42
+
43
+ def generate_id
44
+ time = Time.now.utc
45
+ "#{time.strftime('%Y%m%dT%H%M%S')}#{format('%06d', time.usec)}Z-#{SecureRandom.hex(8)}"
46
+ end
47
+
48
+ def validate_id!(id)
49
+ return id.to_s if id.to_s.match?(ID_PATTERN)
50
+
51
+ raise MessageNotFoundError, 'Message not found'
52
+ end
53
+
54
+ def page_for(records, page:, per_page:, query:)
55
+ filtered = search(records, query)
56
+ sorted = sort_records(filtered)
57
+ normalized_page = normalize_positive(page, 1)
58
+ normalized_per_page = normalize_positive(per_page, MailDude.configuration.default_per_page)
59
+ offset = (normalized_page - 1) * normalized_per_page
60
+ Page.new(records: sorted.slice(offset, normalized_per_page) || [],
61
+ page: normalized_page,
62
+ per_page: normalized_per_page,
63
+ total_count: sorted.length)
64
+ end
65
+
66
+ def prune_ids(records, max_messages:, retention_period:)
67
+ sorted = sort_records(records)
68
+ expired_ids = retention_period ? sorted.select { |record| expired?(record, retention_period) }.map(&:id) : []
69
+ extra_ids = max_messages ? sorted.drop(max_messages.to_i).map(&:id) : []
70
+ (expired_ids + extra_ids).uniq
71
+ end
72
+
73
+ def sort_records(records)
74
+ records.sort_by { |record| record.metadata['captured_at'].to_s }.reverse
75
+ end
76
+
77
+ def search(records, query)
78
+ return records if query.to_s.strip.blank?
79
+
80
+ needle = query.to_s.downcase
81
+ records.select { |record| searchable_text(record).downcase.include?(needle) }
82
+ end
83
+
84
+ def searchable_text(record)
85
+ metadata = record.metadata
86
+ values = %w[subject message_id mailer mailer_action].map { |key| metadata[key] }
87
+ values += %w[from to cc bcc].flat_map { |key| Array(metadata[key]) }
88
+ values.compact.join(' ')
89
+ end
90
+
91
+ def expired?(record, retention_period)
92
+ captured_at = Time.iso8601(record.metadata['captured_at'].to_s)
93
+ captured_at < Time.now.utc - retention_period
94
+ rescue ArgumentError, TypeError
95
+ false
96
+ end
97
+
98
+ def normalize_positive(value, fallback)
99
+ integer = Integer(value)
100
+ integer.positive? ? integer : fallback
101
+ rescue ArgumentError, TypeError
102
+ fallback
103
+ end
104
+ end
105
+ end
106
+ end