mailbox_gem 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fa8d40772489423cacc25cb31361764217a829ea682e0feaabae1c7ad9a8ac7f
4
+ data.tar.gz: a4c11a6abf57765980328193a6dab0e212e74edfc2d207b9c603cd54be67ca05
5
+ SHA512:
6
+ metadata.gz: f3be8e4cf7f0b2e8a88d4a707f1000f151917df1a5991006d6f1b421ed5e76f657254a175e522181a6add6264472cbfc24c8c104dfdcc35494e75c29d7f35d50
7
+ data.tar.gz: 83b0fffc3a4130c43db73c44e2232c93d700a39bf11505d28d0045abe4b061f1747478815d440c0aebab4051d24821108a9dd8e97e8c5bfd744aeba0b255e176
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # MailboxGem
2
+
3
+ <img width="1774" height="887" alt="mailbox_banner" src="https://github.com/user-attachments/assets/a8c594e1-8e11-48e1-ad3c-fe71fa25b317" />
4
+
5
+
6
+ A development-only email inbox for Rails, inspired by [Phoenix's `/dev/mailbox`](https://hexdocs.pm/swoosh/Swoosh.Adapters.Local.html). Point `ActionMailer` at it in development and every email your app actually sends — through its real mailer code paths, not a hand-written preview — shows up in a live, searchable inbox in your browser.
7
+
8
+ MailboxGem is a standalone engine. It doesn't modify Rails or Action Mailer; it registers itself as one more delivery method, the same extension point `letter_opener` and `mailcatcher` use.
9
+
10
+ ## Why
11
+
12
+ Rails already has two ways to look at outgoing mail in development, and both have a gap:
13
+
14
+ - **`ActionMailer::Preview`** (`/rails/mailers`) renders a mailer against data you construct by hand in a preview class. It never touches your real application code path, so it can drift from what actually gets sent.
15
+ - **`ActionMailer::Base.deliveries`** only accumulates anything when `delivery_method` is `:test`, which is what the `test` environment uses — not `development`. In development there's normally nowhere to look at all.
16
+
17
+ MailboxGem fills that gap: it captures the real `Mail::Message` your app hands to Action Mailer when a user actually triggers an email (signs up, resets a password, checks out), and gives you a live UI to inspect it — HTML, plain text, and raw source, with attachments.
18
+
19
+ ## Features
20
+
21
+ - **Captures real mail**, not previews — anything delivered through Action Mailer while `mailbox_gem` is the active delivery method.
22
+ - **Live-updating inbox** — the message list polls in the background and updates without a page reload.
23
+ - **Search-as-you-type** — filters by from/to/subject with no page reload and no query language, debounced so it doesn't hammer the server.
24
+ - **HTML / Text / Source tabs** per message, with attachments listed and sized. HTML is rendered in a sandboxed `iframe` — captured mail is untrusted content and never executes scripts against your app's page.
25
+ - **Zero-config storage** — in-memory only, no database, no migration to install.
26
+
27
+ ## Requirements
28
+
29
+ - Ruby >= 3.2
30
+ - Rails >= 8.0
31
+
32
+ ## Installation
33
+
34
+ Add it to your `development` group — this is a dev tool and has no reason to load in `production` or `test`:
35
+
36
+ ```ruby
37
+ group :development do
38
+ gem "mailbox_gem"
39
+ end
40
+ ```
41
+
42
+ ```bash
43
+ bundle install
44
+ ```
45
+
46
+ ## Setup
47
+
48
+ **1. Mount the engine**, guarded to development, wherever you'd like it served from:
49
+
50
+ ```ruby
51
+ # config/routes.rb
52
+ mount MailboxGem::Engine => "/mailbox" if Rails.env.development?
53
+ ```
54
+
55
+ **2. Point Action Mailer at it:**
56
+
57
+ ```ruby
58
+ # config/environments/development.rb
59
+ config.action_mailer.delivery_method = :mailbox_gem
60
+ ```
61
+
62
+ **3. Visit `/mailbox`** and send some mail from your app. It'll show up without you doing anything else.
63
+
64
+ Both steps are opt-in on purpose. Mounting is a normal route in your own `config/routes.rb`, and the delivery method only takes effect if you assign it — installing the gem never silently changes how your app already handles mail in development (e.g. if you're already using `letter_opener` or a local SMTP catcher).
65
+
66
+ ## Configuration
67
+
68
+ MailboxGem keeps the most recent messages and drops older ones once that limit is exceeded:
69
+
70
+ ```ruby
71
+ # config/initializers/mailbox_gem.rb
72
+ MailboxGem.max_messages = 500 # defaults to 200
73
+ ```
74
+
75
+ ## Known limitations
76
+
77
+ - **In-memory only.** The mailbox is wiped on every server restart. This is deliberate — the same tradeoff `ActionMailer::Base.deliveries` makes — not a bug.
78
+ - **Single Puma worker only.** Storage is a plain in-process buffer. If your app runs Puma in cluster mode (`WEB_CONCURRENCY > 1`), each worker process gets its own independent mailbox, and the UI will only show whatever mail happened to land on whichever worker served that request. This is fine under Puma's default (`WEB_CONCURRENCY` unset, i.e. one worker); it's a real gap if you deliberately run multiple workers in development.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ bundle install # installs dependencies for the gem and its dummy test app
84
+ bin/rails test # runs the test suite against the dummy app
85
+ bin/rubocop # lints
86
+ ```
87
+
88
+ The dummy Rails app used for manual testing lives under `test/dummy`.
89
+
90
+ ### Testing against multiple Rails versions
91
+
92
+ `mailbox_gem.gemspec` declares `"rails", ">= 8.0"`. CI backs that claim by running the suite against each supported minor via [Appraisal](https://github.com/thoughtbot/appraisal) - see `Appraisals` and the generated `gemfiles/*.gemfile`:
93
+
94
+ ```bash
95
+ BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bin/rails test
96
+ BUNDLE_GEMFILE=gemfiles/rails_8.1.gemfile bin/rails test
97
+ ```
98
+
99
+ After changing `Appraisals` or the gemspec's dependencies, regenerate and commit the gemfiles:
100
+
101
+ ```bash
102
+ bundle exec appraisal generate
103
+ ```
104
+
105
+ ## Contributing
106
+
107
+ Bug reports and pull requests are welcome. This gem is meant to stay small and dependency-light — before adding a new capability, it's worth asking whether it fits a zero-config, drop-in dev tool, or belongs as configuration a host app opts into instead.
108
+
109
+ ## License
110
+
111
+ Released under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,5 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import AutoRefreshController from "mailbox_gem/controllers/auto_refresh_controller"
3
+
4
+ const application = Application.start()
5
+ application.register("auto-refresh", AutoRefreshController)
@@ -0,0 +1,58 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Keeps resultsTarget in sync with baseUrlValue+queryValue two ways: a
4
+ // periodic poll (new mail arriving), and live as the user types in the
5
+ // search input (debounced, so it's not one fetch per keystroke).
6
+ //
7
+ // Only resultsTarget's contents are ever replaced, never the controller's
8
+ // own element - the search input has to survive a refresh mid-keystroke.
9
+ //
10
+ // Progressive enhancement: the form is a real GET fallback if this never
11
+ // connects (JS disabled). #submit prevents that navigation when it can,
12
+ // since a full page reload defeats the point of "no reload" search.
13
+ export default class extends Controller {
14
+ static targets = [ "results", "search" ]
15
+ static values = {
16
+ baseUrl: String,
17
+ query: String,
18
+ interval: { type: Number, default: 3000 },
19
+ debounce: { type: Number, default: 300 },
20
+ }
21
+
22
+ connect() {
23
+ this.requestId = 0
24
+ this.timer = setInterval(() => this.refresh(), this.intervalValue)
25
+ }
26
+
27
+ disconnect() {
28
+ clearInterval(this.timer)
29
+ clearTimeout(this.debounceTimer)
30
+ }
31
+
32
+ search(event) {
33
+ this.queryValue = event.target.value
34
+
35
+ clearTimeout(this.debounceTimer)
36
+ this.debounceTimer = setTimeout(() => this.refresh(), this.debounceValue)
37
+ }
38
+
39
+ submit(event) {
40
+ event.preventDefault()
41
+ clearTimeout(this.debounceTimer)
42
+ this.refresh()
43
+ }
44
+
45
+ async refresh() {
46
+ const url = this.queryValue
47
+ ? `${this.baseUrlValue}?q=${encodeURIComponent(this.queryValue)}`
48
+ : this.baseUrlValue
49
+
50
+ // The debounced keystroke fetch and the periodic poll can race; a slow
51
+ // response for an older query shouldn't ever overwrite a newer one.
52
+ const requestId = ++this.requestId
53
+ const response = await fetch(url, { headers: { Accept: "text/html" } })
54
+ if (response.ok && requestId === this.requestId) {
55
+ this.resultsTarget.innerHTML = await response.text()
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,263 @@
1
+ :root {
2
+ /* Ruby Brand */
3
+ --ruby: #CC342D;
4
+ --ruby-dark: #A91401;
5
+ --rails: #D30001;
6
+
7
+ /* Neutrals */
8
+ --bg: #F8FAFC;
9
+ --surface: #FFFFFF;
10
+ --surface-alt: #F3F4F6;
11
+
12
+ --text: #1E1E1E;
13
+ --muted: #6B7280;
14
+ --border: #E5E7EB;
15
+
16
+ /* Accent */
17
+ --link: #3B82F6;
18
+ --success: #16A34A;
19
+
20
+ --radius: 8px;
21
+ }
22
+
23
+
24
+ body {
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
26
+ color: var(--text);
27
+ background: var(--bg);
28
+ margin: 0;
29
+ padding: 2rem;
30
+ }
31
+
32
+ h1 {
33
+ margin: 0 0 1rem;
34
+ font-size: 2rem;
35
+ font-weight: 700;
36
+ color: var(--ruby);
37
+ }
38
+
39
+ .mailbox-header {
40
+ display: flex;
41
+ justify-content: space-between;
42
+ align-items: center;
43
+ margin-bottom: 1rem;
44
+ }
45
+
46
+ .back-link {
47
+ display: inline-block;
48
+ margin-bottom: 1rem;
49
+ color: var(--muted);
50
+ text-decoration: none;
51
+ font-size: 0.875rem;
52
+ }
53
+
54
+ .back-link:hover {
55
+ color: var(--ruby);
56
+ }
57
+
58
+ .search-form {
59
+ display: flex;
60
+ gap: .5rem;
61
+ margin-bottom: 1rem;
62
+ }
63
+
64
+ .search-input {
65
+ flex: 1;
66
+ padding: .5rem .75rem;
67
+ border: 1px solid var(--border);
68
+ border-radius: var(--radius);
69
+ font-size: .875rem;
70
+ color: var(--text);
71
+ background: var(--surface);
72
+ width: 234px;
73
+ padding-left: 2.2rem;
74
+ }
75
+
76
+ .search-input:focus {
77
+ outline: none;
78
+ border-color: var(--ruby);
79
+ }
80
+
81
+ .search-submit {
82
+ padding: .5rem 1rem;
83
+ border: 1px solid var(--border);
84
+ border-radius: var(--radius);
85
+ background: var(--surface);
86
+ color: var(--text);
87
+ font-size: .875rem;
88
+ cursor: pointer;
89
+ }
90
+
91
+ .search-submit:hover {
92
+ border-color: var(--ruby);
93
+ color: var(--ruby);
94
+ }
95
+
96
+ .search-icon {
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ width: 18px;
101
+ height: 18px;
102
+ position: absolute;
103
+ left: .75rem;
104
+ top: 50%;
105
+ transform: translateY(-50%);
106
+ opacity: 50%;
107
+
108
+ }
109
+
110
+ .search-input-wrapper {
111
+ display: flex;
112
+ align-items: center;
113
+ flex: 1;
114
+ position: relative;
115
+
116
+ }
117
+
118
+ .clear-button {
119
+ display: block;
120
+ margin: 0 0 0.75rem auto;
121
+ background: var(--surface);
122
+ border: 1px solid var(--ruby);
123
+ color: var(--ruby);
124
+ border-radius: var(--radius);
125
+ padding: .45rem .9rem;
126
+ font-size: .8rem;
127
+ cursor: pointer;
128
+ transition: .2s;
129
+ }
130
+
131
+ .clear-button:hover {
132
+ background: var(--ruby);
133
+ color: white;
134
+ }
135
+
136
+ table.messages {
137
+ width: 100%;
138
+ border-collapse: collapse;
139
+ background: var(--surface);
140
+ border: 1px solid var(--border);
141
+ border-radius: var(--radius);
142
+ overflow: hidden;
143
+ }
144
+
145
+ table.messages th,
146
+ table.messages td {
147
+ text-align: left;
148
+ padding: .75rem 1rem;
149
+ border-bottom: 1px solid var(--border);
150
+ font-size: .875rem;
151
+ }
152
+
153
+ table.messages th {
154
+ background: rgba(204, 52, 45, .06);
155
+ color: var(--ruby-dark);
156
+ font-weight: 600;
157
+ }
158
+
159
+ table.messages tbody tr:hover {
160
+ background: rgba(204, 52, 45, .04);
161
+ }
162
+
163
+ table.messages tbody tr:last-child td {
164
+ border-bottom: none;
165
+ }
166
+
167
+ table.messages a {
168
+ color: var(--link);
169
+ text-decoration: none;
170
+ }
171
+
172
+ table.messages a:hover {
173
+ color: var(--ruby);
174
+ }
175
+
176
+ .empty-state {
177
+ color: var(--muted);
178
+ }
179
+
180
+ .empty-state code {
181
+ background: rgba(204, 52, 45, .08);
182
+ color: var(--ruby-dark);
183
+ padding: .15rem .4rem;
184
+ border-radius: 4px;
185
+ }
186
+
187
+ dl.headers {
188
+ background: var(--surface);
189
+ border: 1px solid var(--border);
190
+ border-radius: var(--radius);
191
+ padding: 1rem;
192
+ margin-bottom: 1rem;
193
+ display: grid;
194
+ grid-template-columns: max-content 1fr;
195
+ gap: .35rem 1rem;
196
+ }
197
+
198
+ dl.headers dt {
199
+ color: var(--ruby-dark);
200
+ font-weight: 600;
201
+ }
202
+
203
+ dl.headers dd {
204
+ margin: 0;
205
+ }
206
+
207
+ nav.tabs {
208
+ display: flex;
209
+ gap: .5rem;
210
+ margin-bottom: 1rem;
211
+ }
212
+
213
+ nav.tabs .tab {
214
+ padding: .45rem .9rem;
215
+ border: 1px solid transparent;
216
+ border-radius: var(--radius);
217
+ color: var(--muted);
218
+ text-decoration: none;
219
+ transition: .2s;
220
+ }
221
+
222
+ nav.tabs .tab:hover {
223
+ color: var(--ruby);
224
+ }
225
+
226
+ nav.tabs .tab.active {
227
+ background: rgba(204, 52, 45, .08);
228
+ border-color: rgba(204, 52, 45, .2);
229
+ color: var(--ruby);
230
+ font-weight: 600;
231
+ }
232
+
233
+ .body-frame,
234
+ .body-text {
235
+ background: var(--surface);
236
+ border: 1px solid var(--border);
237
+ border-radius: var(--radius);
238
+ }
239
+
240
+ .body-frame {
241
+ width: 100%;
242
+ height: 60vh;
243
+ }
244
+
245
+ .body-text {
246
+ padding: 1rem;
247
+ white-space: pre-wrap;
248
+ word-break: break-word;
249
+ font-size: .875rem;
250
+ }
251
+
252
+ ul.attachments {
253
+ padding-left: 1.25rem;
254
+ font-size: .875rem;
255
+ }
256
+
257
+ ul.attachments li::marker {
258
+ color: var(--ruby);
259
+ }
260
+
261
+ .muted {
262
+ color: var(--muted);
263
+ }
@@ -0,0 +1,4 @@
1
+ module MailboxGem
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,35 @@
1
+ module MailboxGem
2
+ class MessagesController < ApplicationController
3
+ def index
4
+ @query = params[:q].presence
5
+ @messages = filtered_messages
6
+ end
7
+
8
+ # Polled by the auto-refresh Stimulus controller - same data as #index,
9
+ # rendered without the surrounding page so it can replace just the table.
10
+ # Takes the same q param so a poll mid-search stays scoped to it instead
11
+ # of silently reverting to the unfiltered list.
12
+ def table
13
+ @query = params[:q].presence
14
+ render partial: "table", locals: { messages: filtered_messages, query: @query }
15
+ end
16
+
17
+ def show
18
+ @message = Store.find(params[:id])
19
+ return head :not_found unless @message
20
+
21
+ @view = params[:view].presence || (@message.html_body ? "html" : "text")
22
+ end
23
+
24
+ def clear
25
+ Store.clear
26
+ redirect_to messages_path
27
+ end
28
+
29
+ private
30
+
31
+ def filtered_messages
32
+ Store.all.select { |message| message.matches?(@query) }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ module MailboxGem
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module MailboxGem
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module MailboxGem
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module MailboxGem
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Mailbox gem</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "mailbox_gem/application", media: "all" %>
11
+
12
+ <%# Hand-written import map, not the importmap-rails gem: we only have a
13
+ fixed handful of modules, and this avoids requiring the host app to
14
+ manage config/importmap.rb for a UI that lives entirely on its own
15
+ page. asset_path resolves each specifier to its Propshaft-fingerprinted
16
+ URL, which is why this has to be rendered server-side rather than
17
+ living in a static JS file. %>
18
+ <script type="importmap">
19
+ {
20
+ "imports": {
21
+ "@hotwired/stimulus": "<%= asset_path('stimulus.min.js') %>",
22
+ "mailbox_gem/application": "<%= asset_path('mailbox_gem/application.js') %>",
23
+ "mailbox_gem/controllers/auto_refresh_controller": "<%= asset_path('mailbox_gem/controllers/auto_refresh_controller.js') %>"
24
+ }
25
+ }
26
+ </script>
27
+ <script type="module">import "mailbox_gem/application"</script>
28
+ </head>
29
+ <body>
30
+
31
+ <%= yield %>
32
+
33
+ </body>
34
+ </html>
@@ -0,0 +1,6 @@
1
+ <tr>
2
+ <td><%= link_to message.from, message_path(message.id) %></td>
3
+ <td><%= message.to %></td>
4
+ <td><%= link_to message.subject.presence || "(no subject)", message_path(message.id) %></td>
5
+ <td><%= message.captured_at.strftime("%d %B, %Y, %I:%M %p") %></td>
6
+ </tr>
@@ -0,0 +1,34 @@
1
+ <%= form_with url: messages_path,
2
+ method: :get,
3
+ class: "search-form",
4
+ data: { action: "submit->auto-refresh#submit" } do |f| %>
5
+
6
+ <div class="search-input-wrapper">
7
+ <div class="search-icon">
8
+ <svg
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ fill="none"
11
+ viewBox="0 0 24 24"
12
+ stroke-width="1.5"
13
+ stroke="currentColor"
14
+ class="size-6"
15
+ >
16
+ <path
17
+ stroke-linecap="round"
18
+ stroke-linejoin="round"
19
+ d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
20
+ />
21
+ </svg>
22
+ </div>
23
+ <%= f.search_field :q,
24
+ value: query,
25
+ placeholder: "Search from, to, subject…",
26
+ class: "search-input",
27
+ data: {
28
+ auto_refresh_target: "search",
29
+ action: "input->auto-refresh#search",
30
+ } %>
31
+
32
+ </div>
33
+
34
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <% if messages.any? %>
2
+ <%= button_to "Clear all", clear_messages_path, method: :delete, class: "clear-button" %>
3
+
4
+ <table class="messages">
5
+ <thead>
6
+ <tr>
7
+ <th>From</th>
8
+ <th>To</th>
9
+ <th>Subject</th>
10
+ <th>Captured</th>
11
+ </tr>
12
+ </thead>
13
+ <tbody>
14
+ <%= render partial: "message_row", collection: messages, as: :message %>
15
+ </tbody>
16
+ </table>
17
+ <% elsif query.present? %>
18
+ <p class="empty-state">No mail matches "<%= query %>".</p>
19
+ <% else %>
20
+ <p class="empty-state">
21
+ No mail captured yet. Set
22
+ <code>config.action_mailer.delivery_method = :mailbox_gem</code>
23
+ in your development environment, then send some mail.
24
+ </p>
25
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <div
2
+ data-controller="auto-refresh"
3
+ data-auto-refresh-base-url-value="<%= table_messages_path %>"
4
+ data-auto-refresh-query-value="<%= @query %>"
5
+ >
6
+ <header class="mailbox-header">
7
+ <h1>Mailbox</h1>
8
+ <%= render "search", query: @query %>
9
+ </header>
10
+
11
+ <div data-auto-refresh-target="results">
12
+ <%= render "table", messages: @messages, query: @query %>
13
+ </div>
14
+ </div>
@@ -0,0 +1,57 @@
1
+ <%= link_to "← Back to mailbox", messages_path, class: "back-link" %>
2
+
3
+ <h1><%= @message.subject.presence || "(no subject)" %></h1>
4
+
5
+ <dl class="headers">
6
+ <dt>From</dt><dd><%= @message.from %></dd>
7
+ <dt>To</dt><dd><%= @message.to %></dd>
8
+ <% if @message.cc.present? %>
9
+ <dt>Cc</dt><dd><%= @message.cc %></dd>
10
+ <% end %>
11
+ <% if @message.bcc.present? %>
12
+ <dt>Bcc</dt><dd><%= @message.bcc %></dd>
13
+ <% end %>
14
+ <dt>Captured</dt><dd><%= @message.captured_at.strftime("%d %B, %Y, %I:%M %p") %></dd>
15
+ </dl>
16
+
17
+ <nav class="tabs">
18
+ <% if @message.html_body %>
19
+ <%= link_to "HTML",
20
+ message_path(@message.id, view: "html"),
21
+ class: ["tab", ("active" if @view == "html")] %>
22
+ <% end %>
23
+ <% if @message.text_body %>
24
+ <%= link_to "Text",
25
+ message_path(@message.id, view: "text"),
26
+ class: ["tab", ("active" if @view == "text")] %>
27
+ <% end %>
28
+ <%= link_to "Source",
29
+ message_path(@message.id, view: "source"),
30
+ class: ["tab", ("active" if @view == "source")] %>
31
+ </nav>
32
+
33
+ <% case @view %>
34
+ <% when "html" %>
35
+ <iframe
36
+ class="body-frame"
37
+ sandbox=""
38
+ title="HTML body"
39
+ referrerpolicy="no-referrer"
40
+ srcdoc="<%= @message.html_body %>"
41
+ >
42
+ </iframe>
43
+ <% when "text" %>
44
+ <pre class="body-text"><%= @message.text_body %></pre>
45
+ <% when "source" %>
46
+ <pre class="body-text"><%= @message.source %></pre>
47
+ <% end %>
48
+
49
+ <% if @message.attachments.any? %>
50
+ <h2>Attachments</h2>
51
+ <ul class="attachments">
52
+ <% @message.attachments.each do |attachment| %>
53
+ <li><%= attachment.filename %>
54
+ <span class="muted">(<%= number_to_human_size(attachment.decoded.bytesize) %>)</span></li>
55
+ <% end %>
56
+ </ul>
57
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ MailboxGem::Engine.routes.draw do
2
+ root to: "messages#index"
3
+
4
+ resources :messages, only: [ :index, :show ] do
5
+ collection do
6
+ get :table
7
+ end
8
+ end
9
+ delete "messages", to: "messages#clear", as: :clear_messages
10
+ end
@@ -0,0 +1,22 @@
1
+ module MailboxGem
2
+ # A Mail delivery method, in the same sense that Mail::SMTP or
3
+ # Mail::TestMailer are: something Mail::Message#deliver! can call
4
+ # #deliver!(self) on. Registered with ActionMailer::Base.add_delivery_method
5
+ # so a host app opts in with one config line, exactly like letter_opener or
6
+ # mailcatcher-rails do - not auto-enabled, since silently intercepting a
7
+ # host app's mail delivery without consent would break setups that already
8
+ # capture mail another way.
9
+ #
10
+ # This intentionally does not send anything, the same way Swoosh's local
11
+ # adapter or Mail::TestMailer don't: capturing dev mail should not depend
12
+ # on network access or a running SMTP relay.
13
+ class DeliveryMethod
14
+ def initialize(settings)
15
+ @settings = settings
16
+ end
17
+
18
+ def deliver!(mail)
19
+ Store.add(mail)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ module MailboxGem
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace MailboxGem
4
+
5
+ # ActiveSupport.on_load(:action_mailer) defers this until ActionMailer::Base
6
+ # is actually loaded, instead of referencing the constant at boot (which
7
+ # would force-load Action Mailer early and break eager_load ordering).
8
+ # This is the same hook premailer-rails and other mailer-extending gems use.
9
+ initializer "mailbox_gem.delivery_method" do
10
+ ActiveSupport.on_load(:action_mailer) do
11
+ add_delivery_method :mailbox_gem, MailboxGem::DeliveryMethod
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ require "securerandom"
2
+
3
+ module MailboxGem
4
+ # Wraps a Mail::Message captured off the delivery path.
5
+ #
6
+ # We wrap rather than expose Mail::Message directly so the controller/views
7
+ # depend on a small, stable interface instead of the mail gem's API -
8
+ # html_part/text_part branching on multipart? lives here, once, not in ERB.
9
+ class Message
10
+ attr_reader :id, :captured_at, :mail
11
+
12
+ def initialize(mail)
13
+ @id = SecureRandom.uuid
14
+ # Time.now, not Time.current: Time.current follows the host app's
15
+ # Time.zone (UTC by default in Rails), which has nothing to do with
16
+ # what a developer watching their own dev server actually wants here -
17
+ # their own machine's clock. Time.now always reads the OS's local zone.
18
+ @captured_at = Time.now
19
+ @mail = mail
20
+ end
21
+
22
+ def subject
23
+ mail.subject
24
+ end
25
+
26
+ def from
27
+ Array(mail.from).join(", ")
28
+ end
29
+
30
+ def to
31
+ Array(mail.to).join(", ")
32
+ end
33
+
34
+ def cc
35
+ Array(mail.cc).join(", ")
36
+ end
37
+
38
+ def bcc
39
+ Array(mail.bcc).join(", ")
40
+ end
41
+
42
+ def html_body
43
+ if mail.multipart?
44
+ mail.html_part&.decoded
45
+ elsif mail.mime_type == "text/html"
46
+ mail.body.decoded
47
+ end
48
+ end
49
+
50
+ def text_body
51
+ if mail.multipart?
52
+ mail.text_part&.decoded
53
+ elsif mail.mime_type.nil? || mail.mime_type == "text/plain"
54
+ mail.body.decoded
55
+ end
56
+ end
57
+
58
+ def attachments
59
+ mail.attachments
60
+ end
61
+
62
+ def source
63
+ mail.to_s
64
+ end
65
+
66
+ # Plain substring match, not a query language - the fields searched are
67
+ # exactly the ones shown in the index table, so results stay predictable.
68
+ def matches?(query)
69
+ return true if query.blank?
70
+
71
+ [ subject, from, to ].any? { |field| field.to_s.downcase.include?(query.downcase) }
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,58 @@
1
+ module MailboxGem
2
+ # In-memory, thread-safe capture buffer.
3
+ #
4
+ # Deliberately not ActiveRecord-backed: this is a dev-only tool, and
5
+ # requiring a migration to install it would violate the "drop the gem in
6
+ # and go" experience we're after. The tradeoff, same one ActionMailer::Base
7
+ # .deliveries makes, is that the mailbox is wiped on process restart -
8
+ # acceptable for a tool whose job is to show you what *this* dev session
9
+ # sent.
10
+ #
11
+ # Puma's default dev config runs multiple threads, and Mail::Message#deliver!
12
+ # can be called concurrently across requests, so writes are Mutex-guarded.
13
+ #
14
+ # This only protects against races between threads in one process. It does
15
+ # NOT work across Puma worker processes (WEB_CONCURRENCY > 1) - each worker
16
+ # forks its own heap, so each gets its own independent Store, and the
17
+ # mailbox UI would silently show only whatever a given worker happened to
18
+ # capture. Unsupported for now: WEB_CONCURRENCY defaults to 1 (no cluster
19
+ # mode) in a standard Rails dev setup, and going cross-process-safe would
20
+ # mean file or DB-backed storage, a real cost for what's meant to stay a
21
+ # zero-config dev tool.
22
+ class Store
23
+ class << self
24
+ def add(mail)
25
+ message = Message.new(mail)
26
+
27
+ mutex.synchronize do
28
+ messages.unshift(message)
29
+ messages.pop while messages.size > MailboxGem.max_messages
30
+ end
31
+
32
+ message
33
+ end
34
+
35
+ def all
36
+ mutex.synchronize { messages.dup }
37
+ end
38
+
39
+ def find(id)
40
+ mutex.synchronize { messages.find { |message| message.id == id } }
41
+ end
42
+
43
+ def clear
44
+ mutex.synchronize { messages.clear }
45
+ end
46
+
47
+ private
48
+
49
+ def messages
50
+ @messages ||= []
51
+ end
52
+
53
+ def mutex
54
+ @mutex ||= Mutex.new
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,3 @@
1
+ module MailboxGem
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,20 @@
1
+ require "active_support/core_ext/module/attribute_accessors"
2
+
3
+ # Bundler.require only requires gems listed directly in the host app's
4
+ # Gemfile, not our own gemspec's runtime dependencies - so stimulus-rails'
5
+ # Stimulus::Engine would never be defined, and Rails would never load its
6
+ # vendored stimulus.min.js asset, unless we require it ourselves here.
7
+ require "stimulus-rails"
8
+
9
+ require "mailbox_gem/version"
10
+ require "mailbox_gem/message"
11
+ require "mailbox_gem/store"
12
+ require "mailbox_gem/delivery_method"
13
+ require "mailbox_gem/engine"
14
+
15
+ module MailboxGem
16
+ # Maximum number of captured messages to retain. Oldest are dropped once
17
+ # this is exceeded - see Store#add. Override in an initializer:
18
+ # MailboxGem.max_messages = 500
19
+ mattr_accessor :max_messages, default: 200
20
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :mailbox_gem do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailbox_gem
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Luka Tchelidze
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: stimulus-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Captures mail your app actually sends in development - through its real
41
+ Action Mailer delivery path, not a hand-written preview - and shows it in a live,
42
+ searchable inbox in the browser.
43
+ email:
44
+ - lukachelidze3008@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - README.md
50
+ - Rakefile
51
+ - app/assets/javascripts/mailbox_gem/application.js
52
+ - app/assets/javascripts/mailbox_gem/controllers/auto_refresh_controller.js
53
+ - app/assets/stylesheets/mailbox_gem/application.css
54
+ - app/controllers/mailbox_gem/application_controller.rb
55
+ - app/controllers/mailbox_gem/messages_controller.rb
56
+ - app/helpers/mailbox_gem/application_helper.rb
57
+ - app/jobs/mailbox_gem/application_job.rb
58
+ - app/mailers/mailbox_gem/application_mailer.rb
59
+ - app/models/mailbox_gem/application_record.rb
60
+ - app/views/layouts/mailbox_gem/application.html.erb
61
+ - app/views/mailbox_gem/messages/_message_row.html.erb
62
+ - app/views/mailbox_gem/messages/_search.html.erb
63
+ - app/views/mailbox_gem/messages/_table.html.erb
64
+ - app/views/mailbox_gem/messages/index.html.erb
65
+ - app/views/mailbox_gem/messages/show.html.erb
66
+ - config/routes.rb
67
+ - lib/mailbox_gem.rb
68
+ - lib/mailbox_gem/delivery_method.rb
69
+ - lib/mailbox_gem/engine.rb
70
+ - lib/mailbox_gem/message.rb
71
+ - lib/mailbox_gem/store.rb
72
+ - lib/mailbox_gem/version.rb
73
+ - lib/tasks/mailbox_gem_tasks.rake
74
+ homepage: https://github.com/Null-logic-0/mailbox_gem
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ allowed_push_host: https://rubygems.org
79
+ homepage_uri: https://github.com/Null-logic-0/mailbox_gem
80
+ source_code_uri: https://github.com/Null-logic-0/mailbox_gem
81
+ changelog_uri: https://github.com/Null-logic-0/mailbox_gem/blob/main/CHANGELOG.md
82
+ bug_tracker_uri: https://github.com/Null-logic-0/mailbox_gem/issues
83
+ rubygems_mfa_required: 'true'
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.2'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 4.0.3
99
+ specification_version: 4
100
+ summary: A development-only email inbox for Rails, inspired by Phoenix's /dev/mailbox.
101
+ test_files: []