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,134 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>MailDude</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <% if MailDude.configuration.live_updates %>
7
+ <meta name="action-cable-url" content="/cable">
8
+ <% end %>
9
+ <%= csrf_meta_tags %>
10
+ <%= stylesheet_link_tag "mail_dude/application", media: "all" %>
11
+ </head>
12
+ <body class="mail-dude">
13
+ <main class="mail-dude-shell">
14
+ <% if notice.present? %>
15
+ <p class="mail-dude-notice"><%= notice %></p>
16
+ <% end %>
17
+ <% if MailDude.configuration.live_updates %>
18
+ <div class="mail-dude-live-banner" data-mail-dude-live-banner hidden aria-live="polite"></div>
19
+ <% end %>
20
+ <%= yield %>
21
+ </main>
22
+ <% if MailDude.configuration.live_updates %>
23
+ <script>
24
+ (function() {
25
+ var banner = document.querySelector("[data-mail-dude-live-banner]");
26
+ if (!banner || !window.WebSocket) return;
27
+
28
+ function parsedMessage(event) {
29
+ try {
30
+ var data = JSON.parse(event.data);
31
+ return data.message;
32
+ } catch (error) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function appendText(element, tagName, text) {
38
+ if (!text) return;
39
+
40
+ var child = document.createElement(tagName);
41
+ child.textContent = text;
42
+ element.appendChild(child);
43
+ }
44
+
45
+ function messageExists(list, id) {
46
+ var items = list.querySelectorAll("[data-mail-dude-message-id]");
47
+
48
+ for (var index = 0; index < items.length; index += 1) {
49
+ if (items[index].getAttribute("data-mail-dude-message-id") === id) return true;
50
+ }
51
+
52
+ return false;
53
+ }
54
+
55
+ function trimList(list, perPage) {
56
+ if (!perPage) return;
57
+
58
+ while (list.children.length > perPage) {
59
+ list.removeChild(list.lastElementChild);
60
+ }
61
+ }
62
+
63
+ function prependMessage(message) {
64
+ var container = document.querySelector("[data-mail-dude-list-container]");
65
+ if (!container || container.getAttribute("data-mail-dude-live-list-enabled") !== "true") return false;
66
+
67
+ var list = container.querySelector("[data-mail-dude-message-list]");
68
+ var basePath = container.getAttribute("data-mail-dude-messages-path");
69
+ var id = String(message.id || "");
70
+ if (!list || !basePath || !id) return false;
71
+ if (messageExists(list, id)) return true;
72
+
73
+ var item = document.createElement("li");
74
+ item.setAttribute("data-mail-dude-message-id", id);
75
+
76
+ var link = document.createElement("a");
77
+ link.className = "mail-dude-list-item";
78
+ link.href = basePath.replace(/\/$/, "") + "/" + encodeURIComponent(id);
79
+ appendText(link, "strong", message.subject);
80
+ appendText(link, "span", message.sender);
81
+ appendText(link, "span", message.recipients);
82
+ appendText(link, "span", message.captured_at);
83
+ if (message.attachments_count > 0) appendText(link, "span", message.attachment_count_label);
84
+ if (message.mailer_label && message.mailer_label !== "(unknown mailer)") {
85
+ appendText(link, "span", message.mailer_label);
86
+ }
87
+
88
+ item.appendChild(link);
89
+ list.insertBefore(item, list.firstChild);
90
+ list.hidden = false;
91
+
92
+ var emptyState = container.querySelector("[data-mail-dude-empty-state]");
93
+ if (emptyState) emptyState.hidden = true;
94
+
95
+ var pagination = container.querySelector("[data-mail-dude-pagination]");
96
+ if (pagination) pagination.hidden = false;
97
+
98
+ trimList(list, parseInt(container.getAttribute("data-mail-dude-per-page"), 10));
99
+ return true;
100
+ }
101
+
102
+ function showBanner(message, inserted) {
103
+ banner.hidden = false;
104
+ banner.textContent = inserted ? "New message captured." : "New message captured. ";
105
+ if (inserted) return;
106
+
107
+ var link = document.createElement("a");
108
+ link.href = window.location.href;
109
+ link.textContent = "Refresh inbox.";
110
+ banner.appendChild(link);
111
+ }
112
+
113
+ var meta = document.querySelector("meta[name='action-cable-url']");
114
+ var path = meta && meta.content ? meta.content : "/cable";
115
+ var protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
116
+ var url = path.match(/^wss?:\/\//) ? path : protocol + window.location.host + path;
117
+ var socket = new WebSocket(url);
118
+ var identifier = JSON.stringify({ channel: "MailDude::MessagesChannel" });
119
+
120
+ socket.addEventListener("open", function() {
121
+ socket.send(JSON.stringify({ command: "subscribe", identifier: identifier }));
122
+ });
123
+
124
+ socket.addEventListener("message", function(event) {
125
+ var message = parsedMessage(event);
126
+ if (!message || message.event !== "message_created") return;
127
+
128
+ showBanner(message, prependMessage(message));
129
+ });
130
+ }());
131
+ </script>
132
+ <% end %>
133
+ </body>
134
+ </html>
@@ -0,0 +1,4 @@
1
+ <div class="mail-dude-empty">
2
+ <p><%= message %></p>
3
+ </div>
4
+
@@ -0,0 +1,5 @@
1
+ <section class="mail-dude-error">
2
+ <h1>MailDude error</h1>
3
+ <p><%= message %></p>
4
+ </section>
5
+
@@ -0,0 +1,44 @@
1
+ <% live_list_enabled = MailDude.configuration.live_updates && query.blank? && page.page == 1 %>
2
+
3
+ <aside class="mail-dude-list"
4
+ aria-label="Captured messages"
5
+ data-mail-dude-list-container
6
+ data-mail-dude-live-list-enabled="<%= live_list_enabled %>"
7
+ data-mail-dude-messages-path="<%= messages_path %>"
8
+ data-mail-dude-per-page="<%= page.per_page %>">
9
+ <div data-mail-dude-empty-state <%= 'hidden' unless page.records.empty? %>>
10
+ <%= render "empty", message: query.present? ? "No messages match your search." : "No captured messages yet." %>
11
+ </div>
12
+
13
+ <ul data-mail-dude-message-list <%= 'hidden' if page.records.empty? %>>
14
+ <% page.records.each do |record| %>
15
+ <% presenter = MailDude::MessagePresenter.new(record) %>
16
+ <li data-mail-dude-message-id="<%= record.id %>">
17
+ <%= link_to message_path(record.id),
18
+ class: "mail-dude-list-item #{'is-active' if mail_dude_selected?(record)}",
19
+ aria: { current: (mail_dude_selected?(record) ? "true" : nil) } do %>
20
+ <strong><%= presenter.subject_label %></strong>
21
+ <span><%= presenter.sender_summary %></span>
22
+ <span><%= presenter.recipient_summary %></span>
23
+ <span><%= presenter.captured_at_label %></span>
24
+ <% if presenter.has_attachments? %>
25
+ <span><%= presenter.attachment_count_label %></span>
26
+ <% end %>
27
+ <% unless presenter.mailer_label == "(unknown mailer)" %>
28
+ <span><%= presenter.mailer_label %></span>
29
+ <% end %>
30
+ <% end %>
31
+ </li>
32
+ <% end %>
33
+ </ul>
34
+
35
+ <nav class="mail-dude-pagination" data-mail-dude-pagination aria-label="Pagination" <%= 'hidden' if page.records.empty? %>>
36
+ <% if page.previous_page %>
37
+ <%= link_to "Previous", messages_path(page: page.previous_page, q: query) %>
38
+ <% end %>
39
+ <span>Page <%= page.page %> of <%= page.total_pages %></span>
40
+ <% if page.next_page %>
41
+ <%= link_to "Next", messages_path(page: page.next_page, q: query) %>
42
+ <% end %>
43
+ </nav>
44
+ </aside>
@@ -0,0 +1,30 @@
1
+ <section class="mail-dude-pane" aria-label="Selected message">
2
+ <% if message.nil? %>
3
+ <%= render "empty", message: "No message selected." %>
4
+ <% else %>
5
+ <% presenter = MailDude::MessagePresenter.new(message) %>
6
+ <header class="mail-dude-pane-header">
7
+ <h2><%= presenter.subject_label %></h2>
8
+ <%= button_to "Delete", message_path(presenter.id), method: :delete, class: "mail-dude-danger" %>
9
+ </header>
10
+ <%= render "metadata", presenter: presenter %>
11
+ <%= render "tabs", presenter: presenter %>
12
+ <% if presenter.has_attachments? %>
13
+ <section class="mail-dude-attachments">
14
+ <h3>Attachments</h3>
15
+ <% if MailDude.configuration.capture_attachments %>
16
+ <ul>
17
+ <% presenter.attachments.each do |attachment| %>
18
+ <li>
19
+ <%= link_to attachment["filename"], attachment_message_path(presenter.id, attachment["id"]) %>
20
+ <span><%= attachment["content_type"] %></span>
21
+ </li>
22
+ <% end %>
23
+ </ul>
24
+ <% else %>
25
+ <p>Attachments were present but capture is disabled.</p>
26
+ <% end %>
27
+ </section>
28
+ <% end %>
29
+ <% end %>
30
+ </section>
@@ -0,0 +1,33 @@
1
+ <dl class="mail-dude-metadata">
2
+ <dt>From</dt>
3
+ <dd><%= mail_dude_address_list(presenter.from) %></dd>
4
+ <% if presenter.sender.present? && presenter.sender != presenter.from %>
5
+ <dt>Sender</dt>
6
+ <dd><%= mail_dude_address_list(presenter.sender) %></dd>
7
+ <% end %>
8
+ <dt>To</dt>
9
+ <dd><%= mail_dude_address_list(presenter.to) %></dd>
10
+ <% if presenter.cc.present? %>
11
+ <dt>Cc</dt>
12
+ <dd><%= mail_dude_address_list(presenter.cc) %></dd>
13
+ <% end %>
14
+ <% if presenter.bcc.present? %>
15
+ <dt>Bcc</dt>
16
+ <dd><%= mail_dude_address_list(presenter.bcc) %></dd>
17
+ <% end %>
18
+ <% if presenter.reply_to.present? %>
19
+ <dt>Reply-To</dt>
20
+ <dd><%= mail_dude_address_list(presenter.reply_to) %></dd>
21
+ <% end %>
22
+ <dt>Captured</dt>
23
+ <dd><%= presenter.captured_at_label %></dd>
24
+ <dt>Message-ID</dt>
25
+ <dd><%= presenter.message_id.presence || "None" %></dd>
26
+ <dt>Content-Type</dt>
27
+ <dd><%= presenter.content_type.presence || "None" %></dd>
28
+ <dt>Mailer</dt>
29
+ <dd><%= presenter.mailer_label %></dd>
30
+ <dt>Size</dt>
31
+ <dd><%= presenter.size_label %></dd>
32
+ </dl>
33
+
@@ -0,0 +1,14 @@
1
+ <nav class="mail-dude-tabs" aria-label="Message body views">
2
+ <%= link_to "HTML", html_message_path(presenter.id), target: "mail-dude-html-frame" %>
3
+ <%= link_to "Text", text_message_path(presenter.id) %>
4
+ <%= link_to "Headers", headers_message_path(presenter.id) %>
5
+ <%= link_to "Raw", raw_message_path(presenter.id) %>
6
+ </nav>
7
+
8
+ <iframe
9
+ class="mail-dude-message-html-frame"
10
+ name="mail-dude-html-frame"
11
+ src="<%= html_message_path(presenter.id) %>"
12
+ sandbox=""
13
+ title="HTML email preview"></iframe>
14
+
@@ -0,0 +1,4 @@
1
+ <section class="mail-dude-error">
2
+ <h1>MailDude error</h1>
3
+ <p><%= message %></p>
4
+ </section>
@@ -0,0 +1,17 @@
1
+ <header class="mail-dude-header">
2
+ <div class="mail-dude-brand">
3
+ <%= image_tag "mail_dude/icon.png", alt: "", class: "mail-dude-logo" %>
4
+ <h1>MailDude</h1>
5
+ </div>
6
+ <%= form_with url: messages_path, method: :get, local: true, class: "mail-dude-search" do |form| %>
7
+ <%= form.label :q, "Search messages" %>
8
+ <%= form.search_field :q, value: @query, placeholder: "Search..." %>
9
+ <%= form.submit "Search" %>
10
+ <% end %>
11
+ <%= button_to "Clear All", clear_messages_path, method: :delete, class: "mail-dude-danger" %>
12
+ </header>
13
+
14
+ <section class="mail-dude-layout">
15
+ <%= render "list", page: @page, selected_message: @selected_message, query: @query %>
16
+ <%= render "message_pane", message: @selected_message %>
17
+ </section>
@@ -0,0 +1,2 @@
1
+ <%= render "index" %>
2
+
data/config/routes.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ MailDude::Engine.routes.draw do
4
+ root 'messages#index'
5
+
6
+ resources :messages, only: %i[index show destroy] do
7
+ member do
8
+ get :html
9
+ get :text
10
+ get :headers
11
+ get :raw
12
+ get 'attachments/:attachment_id', to: 'attachments#show', as: :attachment
13
+ end
14
+
15
+ collection do
16
+ delete :clear
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMailDudeStoredEmails < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :mail_dude_stored_emails do |t|
6
+ t.string :uid, null: false
7
+ t.datetime :captured_at, null: false
8
+ t.string :subject
9
+ t.text :from_addresses_json
10
+ t.text :sender_addresses_json
11
+ t.text :to_addresses_json
12
+ t.text :cc_addresses_json
13
+ t.text :bcc_addresses_json
14
+ t.text :reply_to_addresses_json
15
+ t.string :message_id
16
+ t.string :content_type
17
+ t.string :mailer
18
+ t.string :mailer_action
19
+ t.boolean :has_html, null: false, default: false
20
+ t.boolean :has_text, null: false, default: false
21
+ t.boolean :has_attachments, null: false, default: false
22
+ t.integer :attachments_count, null: false, default: 0
23
+ t.integer :size_bytes, null: false, default: 0
24
+ t.text :metadata_json, null: false
25
+ t.binary :raw_message, null: false
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ add_index :mail_dude_stored_emails, :uid, unique: true
31
+ add_index :mail_dude_stored_emails, :captured_at
32
+ add_index :mail_dude_stored_emails, :message_id
33
+ add_index :mail_dude_stored_emails, :mailer
34
+ add_index :mail_dude_stored_emails, :mailer_action
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module MailDude
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+ class_option :database, type: :boolean, default: false, desc: 'Install the database storage migration'
13
+
14
+ def copy_initializer
15
+ @storage = options[:database] ? ':database' : ':file'
16
+ template 'initializer.tt', 'config/initializers/mail_dude.rb'
17
+ end
18
+
19
+ def copy_migration
20
+ return unless options[:database]
21
+
22
+ migration_template 'create_mail_dude_stored_emails.tt', 'db/migrate/create_mail_dude_stored_emails.rb'
23
+ end
24
+
25
+ def print_next_steps
26
+ puts <<~TEXT
27
+
28
+ Mount MailDude behind host app authentication and authorization:
29
+
30
+ authenticate :user, lambda { |u| Ability.new(u).can?(:manage, MailDude::Dashboard) } do
31
+ mount MailDude::Engine, at: "/mail_dude"
32
+ end
33
+
34
+ Configure Action Mailer in development or QA:
35
+
36
+ config.action_mailer.delivery_method = :mail_dude
37
+ config.action_mailer.perform_deliveries = true
38
+
39
+ MailDude does not authenticate users itself. Do not expose /mail_dude publicly.
40
+ TEXT
41
+ puts 'Run bin/rails db:migrate before using database storage.' if options[:database]
42
+ end
43
+
44
+ def self.next_migration_number(dirname)
45
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMailDudeStoredEmails < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :mail_dude_stored_emails do |t|
6
+ t.string :uid, null: false
7
+ t.datetime :captured_at, null: false
8
+ t.string :subject
9
+ t.text :from_addresses_json
10
+ t.text :sender_addresses_json
11
+ t.text :to_addresses_json
12
+ t.text :cc_addresses_json
13
+ t.text :bcc_addresses_json
14
+ t.text :reply_to_addresses_json
15
+ t.string :message_id
16
+ t.string :content_type
17
+ t.string :mailer
18
+ t.string :mailer_action
19
+ t.boolean :has_html, null: false, default: false
20
+ t.boolean :has_text, null: false, default: false
21
+ t.boolean :has_attachments, null: false, default: false
22
+ t.integer :attachments_count, null: false, default: 0
23
+ t.integer :size_bytes, null: false, default: 0
24
+ t.text :metadata_json, null: false
25
+ t.binary :raw_message, null: false
26
+
27
+ t.timestamps
28
+ end
29
+
30
+ add_index :mail_dude_stored_emails, :uid, unique: true
31
+ add_index :mail_dude_stored_emails, :captured_at
32
+ add_index :mail_dude_stored_emails, :message_id
33
+ add_index :mail_dude_stored_emails, :mailer
34
+ add_index :mail_dude_stored_emails, :mailer_action
35
+ end
36
+ end
37
+
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ MailDude.configure do |config|
4
+ config.enabled_environments = %w[development qa test]
5
+ config.storage = <%= @storage %>
6
+ config.storage_path = Rails.root.join("tmp", "mail_dude")
7
+ config.max_messages = 1_000
8
+ config.retention_period = 7.days
9
+ config.max_message_size = 25.megabytes
10
+ config.allow_production = false
11
+ config.capture_attachments = true
12
+ config.capture_mailer_metadata_headers = true
13
+ config.default_per_page = 50
14
+ config.live_updates = false
15
+ config.live_update_stream_name = "mail_dude:#{Rails.env}:messages"
16
+ config.live_update_authorizer = ->(_connection) { false }
17
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailDude
4
+ class AttachmentLocator
5
+ Attachment = Struct.new(:id, :part, :metadata, keyword_init: true) do
6
+ def content_type
7
+ metadata['content_type']
8
+ end
9
+
10
+ def data
11
+ part.decoded
12
+ rescue StandardError
13
+ part.body.raw_source.to_s
14
+ end
15
+
16
+ def filename
17
+ metadata['filename']
18
+ end
19
+
20
+ def inline?
21
+ metadata['inline']
22
+ end
23
+ end
24
+
25
+ def initialize(message)
26
+ @message = message
27
+ end
28
+
29
+ def attachments
30
+ return [] unless MailDude.configuration.capture_attachments
31
+
32
+ attachment_parts.each_with_index.map do |part, index|
33
+ id = "a#{index}"
34
+ Attachment.new(id: id, part: part, metadata: metadata_for(part, id))
35
+ end
36
+ end
37
+
38
+ def find(attachment_id)
39
+ raise AttachmentNotFoundError, 'Attachment not found' unless attachment_id.to_s.match?(/\Aa\d+\z/)
40
+
41
+ attachments.find { |attachment| attachment.id == attachment_id.to_s } ||
42
+ raise(AttachmentNotFoundError, 'Attachment not found')
43
+ end
44
+
45
+ def find_inline_by_cid(content_id)
46
+ normalized = normalize_content_id(content_id)
47
+ attachments.find { |attachment| attachment.metadata['content_id'] == normalized }
48
+ end
49
+
50
+ def normalize_content_id(content_id)
51
+ content_id.to_s.delete_prefix('cid:').delete_prefix('<').delete_suffix('>').strip
52
+ end
53
+
54
+ def sanitize_filename(filename, fallback:)
55
+ sanitized = filename.to_s.encode('UTF-8', invalid: :replace, undef: :replace)
56
+ sanitized = sanitized.gsub(%r{[/\\\0[:cntrl:]]}, '_').strip
57
+ sanitized.presence || fallback
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :message
63
+
64
+ def attachment_parts
65
+ return [] unless mail
66
+
67
+ mail.all_parts.select { |part| attachment_part?(part) }
68
+ end
69
+
70
+ def attachment_part?(part)
71
+ !part.multipart? && (part.attachment? || part.filename.present? || inline_part?(part))
72
+ end
73
+
74
+ def content_disposition(part)
75
+ part.content_disposition.to_s.split(';').first.to_s.downcase
76
+ end
77
+
78
+ def inline_part?(part)
79
+ content_disposition(part) == 'inline' && part.content_id.present?
80
+ end
81
+
82
+ def mail
83
+ @mail ||=
84
+ if message.is_a?(Mail::Message)
85
+ message
86
+ elsif message.raw_source.present?
87
+ Mail.read_from_string(message.raw_source)
88
+ end
89
+ rescue StandardError
90
+ nil
91
+ end
92
+
93
+ def metadata_for(part, id)
94
+ {
95
+ 'id' => id,
96
+ 'filename' => sanitize_filename(part.filename, fallback: "attachment-#{id}"),
97
+ 'content_type' => part.mime_type.presence || 'application/octet-stream',
98
+ 'content_id' => normalize_content_id(part.content_id),
99
+ 'disposition' => content_disposition(part).presence || 'attachment',
100
+ 'inline' => content_disposition(part) == 'inline',
101
+ 'size_bytes' => decoded_size(part)
102
+ }
103
+ end
104
+
105
+ def decoded_size(part)
106
+ part.decoded.to_s.bytesize
107
+ rescue StandardError
108
+ part.body.raw_source.to_s.bytesize
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ module MailDude
6
+ class Configuration
7
+ SUPPORTED_STORES = %i[file memory database].freeze
8
+
9
+ attr_accessor :allow_production,
10
+ :capture_attachments,
11
+ :capture_mailer_metadata_headers,
12
+ :default_per_page,
13
+ :enabled_environments,
14
+ :live_update_authorizer,
15
+ :live_update_stream_name,
16
+ :live_updates,
17
+ :max_message_size,
18
+ :max_messages,
19
+ :retention_period,
20
+ :storage,
21
+ :storage_path
22
+
23
+ def initialize
24
+ @enabled_environments = %w[development qa test]
25
+ @storage = :file
26
+ @storage_path = default_storage_path
27
+ @max_messages = 1_000
28
+ @retention_period = 7.days
29
+ @max_message_size = 25.megabytes
30
+ @allow_production = false
31
+ @capture_attachments = true
32
+ @capture_mailer_metadata_headers = true
33
+ @default_per_page = 50
34
+ @live_updates = false
35
+ @live_update_stream_name = default_live_update_stream_name
36
+ @live_update_authorizer = ->(_connection) { false }
37
+ end
38
+
39
+ def validate!
40
+ normalize!
41
+ validate_storage!
42
+ validate_storage_path!
43
+ validate_positive!(:max_messages, max_messages)
44
+ validate_positive!(:retention_period, retention_period)
45
+ validate_positive!(:max_message_size, max_message_size)
46
+ validate_positive!(:default_per_page, default_per_page)
47
+ validate_live_updates!
48
+ self
49
+ end
50
+
51
+ private
52
+
53
+ def default_storage_path
54
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
55
+ Rails.root.join('tmp/mail_dude')
56
+ else
57
+ Pathname.new(Dir.tmpdir).join('mail_dude')
58
+ end
59
+ end
60
+
61
+ def default_live_update_stream_name
62
+ env = if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
63
+ Rails.env
64
+ else
65
+ ENV.fetch('RAILS_ENV', ENV.fetch('RACK_ENV', 'development'))
66
+ end
67
+ "mail_dude:#{env}:messages"
68
+ end
69
+
70
+ def normalize!
71
+ @enabled_environments = Array(enabled_environments).map(&:to_s)
72
+ @storage = storage.to_sym if storage.respond_to?(:to_sym)
73
+ end
74
+
75
+ def validate_storage!
76
+ return if SUPPORTED_STORES.include?(storage)
77
+
78
+ raise InvalidConfigurationError, "storage must be one of: #{SUPPORTED_STORES.join(', ')}"
79
+ end
80
+
81
+ def validate_storage_path!
82
+ return unless storage == :file
83
+ return if storage_path.present?
84
+
85
+ raise InvalidConfigurationError, 'storage_path must be present when storage is :file'
86
+ end
87
+
88
+ def validate_positive!(name, value)
89
+ return if value.nil?
90
+ return if value.respond_to?(:positive?) && value.positive?
91
+
92
+ raise InvalidConfigurationError, "#{name} must be positive when configured"
93
+ end
94
+
95
+ def validate_live_updates!
96
+ raise InvalidConfigurationError, 'live_update_stream_name must be present' if live_update_stream_name.blank?
97
+ return if live_update_authorizer.respond_to?(:call)
98
+
99
+ raise InvalidConfigurationError, 'live_update_authorizer must respond to call'
100
+ end
101
+ end
102
+ end