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,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,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,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>
|
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
|