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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +237 -0
- data/app/assets/images/mail_dude/icon.png +0 -0
- data/app/assets/stylesheets/mail_dude/application.css +180 -0
- data/app/channels/mail_dude/messages_channel.rb +18 -0
- data/app/controllers/mail_dude/application_controller.rb +25 -0
- data/app/controllers/mail_dude/attachments_controller.rb +26 -0
- data/app/controllers/mail_dude/messages_controller.rb +72 -0
- data/app/helpers/mail_dude/application_helper.rb +13 -0
- data/app/models/mail_dude/application_record.rb +7 -0
- data/app/models/mail_dude/stored_email.rb +7 -0
- data/app/views/layouts/mail_dude/application.html.erb +134 -0
- data/app/views/mail_dude/messages/_empty.html.erb +4 -0
- data/app/views/mail_dude/messages/_error.html.erb +5 -0
- data/app/views/mail_dude/messages/_list.html.erb +44 -0
- data/app/views/mail_dude/messages/_message_pane.html.erb +30 -0
- data/app/views/mail_dude/messages/_metadata.html.erb +33 -0
- data/app/views/mail_dude/messages/_tabs.html.erb +14 -0
- data/app/views/mail_dude/messages/error.html.erb +4 -0
- data/app/views/mail_dude/messages/index.html.erb +17 -0
- data/app/views/mail_dude/messages/show.html.erb +2 -0
- data/config/routes.rb +19 -0
- data/db/migrate/20260506170200_create_mail_dude_stored_emails.rb +36 -0
- data/lib/generators/mail_dude/install_generator.rb +49 -0
- data/lib/generators/mail_dude/templates/create_mail_dude_stored_emails.tt +37 -0
- data/lib/generators/mail_dude/templates/initializer.tt +17 -0
- data/lib/mail_dude/attachment_locator.rb +111 -0
- data/lib/mail_dude/configuration.rb +102 -0
- data/lib/mail_dude/dashboard.rb +6 -0
- data/lib/mail_dude/delivery_method.rb +46 -0
- data/lib/mail_dude/engine.rb +24 -0
- data/lib/mail_dude/errors.rb +11 -0
- data/lib/mail_dude/html_body_renderer.rb +49 -0
- data/lib/mail_dude/mailer_metadata_headers.rb +22 -0
- data/lib/mail_dude/message_broadcast.rb +50 -0
- data/lib/mail_dude/message_presenter.rb +136 -0
- data/lib/mail_dude/message_record.rb +17 -0
- data/lib/mail_dude/message_serializer.rb +117 -0
- data/lib/mail_dude/pagination.rb +35 -0
- data/lib/mail_dude/stores/base.rb +106 -0
- data/lib/mail_dude/stores/database_store.rb +93 -0
- data/lib/mail_dude/stores/file_store.rb +126 -0
- data/lib/mail_dude/stores/memory_store.rb +53 -0
- data/lib/mail_dude/version.rb +5 -0
- data/lib/mail_dude.rb +80 -0
- data/lib/tasks/mail_dude.rake +25 -0
- metadata +224 -0
|
@@ -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
|