rails-mailer-sandbox 0.2.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: 8b3769b564df7ce2bf2278546029a59a92ad70ceec2e074ab4550abecbd4055e
4
+ data.tar.gz: ac6f6d14a1e60deadfc5388c42426d0d79e942fe3ba65986199391589c4be47d
5
+ SHA512:
6
+ metadata.gz: 8744e34ffdf4cade29d895b9279c1f38aefd55e0beea7d50ef74387d5418b278c011116b9b16c1ccc70bb15cc7840bb8e5d0aad2e8ae51c8dff8e2aca3eea761
7
+ data.tar.gz: ba697866c07972be8adafcae6de50690cd0c653d3f44f24583ab0e77587070ce84e740f1ce3f6f4777aa55f2b531d152819c0c5032c9f766f0d83dbce67cffe3
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,43 @@
1
+ # Contributing to Rails Mailer Sandbox
2
+
3
+ Bug reports, fixes, tests, documentation, and focused feature proposals are welcome.
4
+
5
+ ## Report Bugs
6
+
7
+ Open GitHub issue with:
8
+
9
+ - Rails, Ruby, Rack, and gem versions
10
+ - Minimal reproduction steps
11
+ - Expected and actual behavior
12
+ - Relevant exception and backtrace
13
+ - Browser name and version for Web UI bugs
14
+
15
+ Never include real email addresses, credentials, tokens, or captured production email.
16
+
17
+ ## Development Setup
18
+
19
+ ```bash
20
+ git clone https://github.com/azmi2409/rails-mailer-sandbox.git
21
+ cd rails-mailer-sandbox
22
+ bundle install
23
+ ruby -Ilib test/rails_mailer_sandbox_test.rb
24
+ ```
25
+
26
+ ## Changes
27
+
28
+ - Keep changes focused and backward compatible after public release.
29
+ - Add or update tests for behavior changes.
30
+ - Preserve Ruby 3.0 compatibility.
31
+ - Avoid new runtime dependencies unless standard library or current dependencies cannot solve need.
32
+ - Keep Web UI dependency-free, responsive, and accessible.
33
+ - Never weaken HTML preview sandbox or filesystem path checks.
34
+
35
+ ## Pull Requests
36
+
37
+ 1. Create branch from `main`.
38
+ 2. Make focused change with test coverage.
39
+ 3. Run `ruby -Ilib test/rails_mailer_sandbox_test.rb`.
40
+ 4. Run `gem build rails-mailer-sandbox.gemspec`.
41
+ 5. Open pull request describing problem, solution, and verification.
42
+
43
+ By contributing, you agree your contribution is licensed under project MIT license.
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Developer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Rails Mailer Sandbox
2
+
3
+ Development email sandbox for Rails. Captures Action Mailer deliveries, stores original
4
+ `.eml` messages on disk, and exposes a Mailtrap-inspired Web UI inside your Rails app.
5
+
6
+ ## Features
7
+
8
+ - Persistent MIME message storage across Rails restarts
9
+ - Search by subject, sender, recipient, or body preview
10
+ - HTML, plain-text, and raw-source views
11
+ - Desktop, tablet, and mobile preview widths
12
+ - From, To, Cc, Bcc, date, and message-size details
13
+ - Attachment downloads
14
+ - Automatic inbox refresh
15
+ - Individual and bulk deletion
16
+ - Sandboxed HTML previews with remote resources and scripts blocked
17
+ - Filesystem locking and atomic writes for Rails and background workers
18
+ - No database or external service
19
+
20
+ ## Installation
21
+
22
+ Add gem to development group:
23
+
24
+ ```ruby
25
+ group :development do
26
+ gem "rails-mailer-sandbox", github: "azmi2409/rails-mailer-sandbox"
27
+ end
28
+ ```
29
+
30
+ Then run:
31
+
32
+ ```bash
33
+ bundle install
34
+ bundle exec rake rails_mailer_sandbox:setup
35
+ ```
36
+
37
+ Restart Rails and open <http://localhost:3000/rails_mailer_sandbox>.
38
+
39
+ Setup creates `config/initializers/rails_mailer_sandbox.rb`, configures Action Mailer,
40
+ and mounts Web UI. Repeating task is safe. Existing edited initializer is never overwritten.
41
+
42
+ ## Manual Setup
43
+
44
+ Configure `config/environments/development.rb`:
45
+
46
+ ```ruby
47
+ config.action_mailer.delivery_method = :rails_mailer_sandbox
48
+ config.action_mailer.perform_deliveries = true
49
+ config.action_mailer.raise_delivery_errors = true
50
+ ```
51
+
52
+ Mount Web UI in `config/routes.rb`:
53
+
54
+ ```ruby
55
+ mount RailsMailerSandbox::Server.new => "/rails_mailer_sandbox" if Rails.env.development?
56
+ ```
57
+
58
+ ## Storage
59
+
60
+ Default path: `Rails.root/tmp/rails_mailer_sandbox`.
61
+
62
+ Override path in initializer:
63
+
64
+ ```ruby
65
+ RailsMailerSandbox.storage_path = Rails.root.join("storage", "development_mail")
66
+ ```
67
+
68
+ Keep custom storage outside version control. Rails `tmp:clear` removes default inbox.
69
+ Local filesystem storage suits development on one host. It does not target large archives
70
+ or distributed deployments.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ ruby -Ilib test/rails_mailer_sandbox_test.rb
76
+ gem build rails-mailer-sandbox.gemspec
77
+ ```
78
+
79
+ See [CONTRIBUTING.md](CONTRIBUTING.md) before opening pull request.
80
+
81
+ ## License
82
+
83
+ [MIT](MIT-LICENSE)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_mailer_sandbox"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RailsMailerSandbox
6
+ module Setup
7
+ def self.run(root)
8
+ root = File.expand_path(root)
9
+ raise "Not a Rails application: #{root}" unless File.file?(File.join(root, "config/application.rb"))
10
+ path = File.join(root, "config/initializers/rails_mailer_sandbox.rb")
11
+ content = <<~RUBY
12
+ # Generated by rails_mailer_sandbox:setup. Development only.
13
+ if Rails.env.development?
14
+ Rails.application.config.action_mailer.delivery_method = :rails_mailer_sandbox
15
+ Rails.application.config.action_mailer.perform_deliveries = true
16
+ Rails.application.config.action_mailer.raise_delivery_errors = true
17
+ RailsMailerSandbox.storage_path = Rails.root.join("tmp", "rails_mailer_sandbox")
18
+
19
+ Rails.application.routes.append do
20
+ mount RailsMailerSandbox::Server.new => "/rails_mailer_sandbox"
21
+ end
22
+ end
23
+ RUBY
24
+ FileUtils.mkdir_p(File.dirname(path))
25
+ begin
26
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |file| file.write(content) }
27
+ rescue Errno::EEXIST
28
+ raise "Existing #{path} differs; review manually." unless File.read(path) == content
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "rack"
6
+ require "securerandom"
7
+ require "time"
8
+ require "date"
9
+ require "fileutils"
10
+ require "mail"
11
+
12
+ module RailsMailerSandbox
13
+ VERSION = "0.2.0"
14
+
15
+ class << self
16
+ attr_writer :storage_path
17
+
18
+ def storage_path
19
+ @storage_path || File.join(defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.to_s : Dir.pwd, "tmp", "rails_mailer_sandbox")
20
+ end
21
+ end
22
+
23
+ class Message
24
+ attr_reader :id, :from, :to, :cc, :bcc, :subject, :date, :text_part,
25
+ :html_part, :raw_source, :attachments
26
+
27
+ def initialize(mail, id: SecureRandom.uuid)
28
+ @id = id
29
+ @date = mail.date || Time.now
30
+ @from = Array(mail.from)
31
+ @to = Array(mail.to)
32
+ @cc = Array(mail.cc)
33
+ @bcc = Array(mail.bcc)
34
+ @subject = mail.subject.to_s
35
+ @attachments = []
36
+ extract_parts(mail)
37
+ @raw_source = mail.to_s
38
+ @raw_source = "Bcc: #{@bcc.join(', ').gsub(/[\r\n]/, '')}\r\n#{@raw_source}" unless @bcc.empty? || @raw_source.match?(/^Bcc:/i)
39
+ end
40
+
41
+ def recipients
42
+ to + cc + bcc
43
+ end
44
+
45
+ def preview
46
+ text_part.to_s.gsub(/\s+/, " ").strip[0, 140]
47
+ end
48
+
49
+ def size
50
+ raw_source.bytesize
51
+ end
52
+
53
+ def matches?(query)
54
+ query.empty? || [subject, *from, *recipients, preview].any? { |value| value.downcase.include?(query) }
55
+ end
56
+
57
+ private
58
+
59
+ def extract_parts(mail)
60
+ parts = mail.multipart? ? mail.all_parts : [mail]
61
+ parts.each do |part|
62
+ if part.respond_to?(:attachment?) && part.attachment?
63
+ @attachments << {
64
+ filename: part.filename.to_s,
65
+ content_type: part.mime_type.to_s,
66
+ body: part.body.decoded
67
+ }
68
+ elsif part.mime_type == "text/html" && !@html_part
69
+ @html_part = part.body.decoded
70
+ elsif part.mime_type == "text/plain" && !@text_part
71
+ @text_part = part.body.decoded
72
+ end
73
+ end
74
+ @text_part ||= @html_part&.gsub(/<[^>]*>/, " ")&.gsub(/\s+/, " ")&.strip
75
+ @html_part ||= "<pre style=\"white-space:pre-wrap\">#{CGI.escapeHTML(@text_part.to_s)}</pre>"
76
+ end
77
+ end
78
+
79
+ class Storage
80
+ class << self
81
+ def add(mail)
82
+ message = Message.new(mail)
83
+ locked do |directory|
84
+ temporary = File.join(directory, ".#{message.id}.tmp")
85
+ begin
86
+ File.open(temporary, "wb", 0o600) do |file|
87
+ file.write(message.raw_source)
88
+ file.flush
89
+ file.fsync
90
+ end
91
+ File.rename(temporary, File.join(directory, "#{message.id}.eml"))
92
+ ensure
93
+ File.delete(temporary) if File.exist?(temporary)
94
+ end
95
+ end
96
+ message
97
+ end
98
+
99
+ def all
100
+ locked do |directory|
101
+ Dir.glob(File.join(directory, "*.eml")).sort_by { |path| [File.mtime(path), path] }.reverse.map do |path|
102
+ Message.new(Mail.read(path), id: File.basename(path, ".eml"))
103
+ end
104
+ end
105
+ end
106
+
107
+ def find(id)
108
+ return unless valid_id?(id)
109
+ locked do |directory|
110
+ path = File.join(directory, "#{id}.eml")
111
+ Message.new(Mail.read(path), id: id) if File.file?(path)
112
+ end
113
+ end
114
+
115
+ def delete(id)
116
+ return unless valid_id?(id)
117
+ locked do |directory|
118
+ path = File.join(directory, "#{id}.eml")
119
+ File.delete(path) if File.file?(path)
120
+ end
121
+ end
122
+
123
+ def clear
124
+ locked { |directory| Dir.glob(File.join(directory, "*.eml")).each { |path| File.delete(path) } }
125
+ end
126
+
127
+ def count
128
+ locked { |directory| Dir.glob(File.join(directory, "*.eml")).size }
129
+ end
130
+
131
+ private
132
+
133
+ def valid_id?(id)
134
+ id.is_a?(String) && id.match?(/\A[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\z/)
135
+ end
136
+
137
+ # ponytail: Local filesystem inbox. Use a database adapter for large inboxes or multi-host deployments.
138
+ def locked
139
+ directory = File.expand_path(RailsMailerSandbox.storage_path)
140
+ FileUtils.mkdir_p(directory, mode: 0o700)
141
+ File.open(File.join(directory, ".lock"), File::RDWR | File::CREAT, 0o600) do |lock|
142
+ lock.flock(File::LOCK_EX)
143
+ yield directory
144
+ ensure
145
+ lock.flock(File::LOCK_UN)
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ class DeliveryMethod
152
+ attr_accessor :settings
153
+
154
+ def initialize(values = {})
155
+ @settings = values
156
+ end
157
+
158
+ def deliver!(mail)
159
+ Storage.add(mail)
160
+ end
161
+ end
162
+
163
+ class Server
164
+ SECURITY_HEADERS = {
165
+ "content-security-policy" => "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'self'",
166
+ "referrer-policy" => "no-referrer",
167
+ "x-content-type-options" => "nosniff",
168
+ "x-frame-options" => "SAMEORIGIN"
169
+ }.freeze
170
+
171
+ def call(env)
172
+ request = Rack::Request.new(env)
173
+ response = route(request)
174
+ response[1] = SECURITY_HEADERS.merge(response[1])
175
+ response
176
+ rescue StandardError => error
177
+ warn "RailsMailerSandbox: #{error.class}: #{error.message}"
178
+ [500, SECURITY_HEADERS.merge("content-type" => "text/plain; charset=utf-8"), ["Rails Mailer Sandbox error"]]
179
+ end
180
+
181
+ private
182
+
183
+ def route(request)
184
+ path = request.path_info
185
+ method = request.request_method
186
+ if method == "POST" && (request.get_header("HTTP_SEC_FETCH_SITE") == "cross-site" ||
187
+ (request.get_header("HTTP_ORIGIN") && request.get_header("HTTP_ORIGIN") != request.base_url))
188
+ return [403, { "content-type" => "text/plain" }, ["Forbidden"]]
189
+ end
190
+
191
+ return render_index(request) if method == "GET" && ["", "/"].include?(path)
192
+ return render_messages_json if method == "GET" && path == "/messages.json"
193
+ return render_part(Regexp.last_match(1), :html_part, "text/html; charset=utf-8") if method == "GET" && path =~ %r{\A/messages/([^/]+)/html\z}
194
+ return render_part(Regexp.last_match(1), :raw_source, "text/plain; charset=utf-8") if method == "GET" && path =~ %r{\A/messages/([^/]+)/raw\z}
195
+ return render_attachment(Regexp.last_match(1), Regexp.last_match(2).to_i) if method == "GET" && path =~ %r{\A/messages/([^/]+)/attachment/(\d+)\z}
196
+ return mutate(request) { Storage.clear } if method == "POST" && path == "/clear"
197
+ return mutate(request) { Storage.delete(Regexp.last_match(1)) } if method == "POST" && path =~ %r{\A/messages/([^/]+)/delete\z}
198
+
199
+ [404, { "content-type" => "text/plain; charset=utf-8" }, ["Not Found"]]
200
+ end
201
+
202
+ def mutate(request)
203
+ yield
204
+ [303, { "location" => "#{base_path(request)}/" }, []]
205
+ end
206
+
207
+ def render_part(id, field, content_type)
208
+ message = Storage.find(id)
209
+ return not_found unless message
210
+
211
+ headers = { "content-type" => content_type }
212
+ headers["content-security-policy"] = "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'" if field == :html_part
213
+ [200, headers, [message.public_send(field).to_s]]
214
+ end
215
+
216
+ def render_attachment(id, index)
217
+ attachment = Storage.find(id)&.attachments&.[](index)
218
+ return not_found unless attachment
219
+
220
+ filename = attachment[:filename].encode("US-ASCII", invalid: :replace, undef: :replace).gsub(/[\x00-\x1f\x7f"\\]/, "_")
221
+ type = attachment[:content_type].match?(/\A[-\w.+]+\/[-\w.+]+\z/) ? attachment[:content_type] : "application/octet-stream"
222
+ [200, {
223
+ "content-type" => type,
224
+ "content-disposition" => %(attachment; filename="#{filename}"),
225
+ "content-length" => attachment[:body].bytesize.to_s
226
+ }, [attachment[:body]]]
227
+ end
228
+
229
+ def render_messages_json
230
+ payload = Storage.all.map do |message|
231
+ { id: message.id, subject: message.subject, from: message.from, to: message.to, date: message.date.iso8601 }
232
+ end
233
+ [200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [JSON.generate(payload)]]
234
+ end
235
+
236
+ def render_index(request)
237
+ query = request.params.fetch("q", "").to_s.strip.downcase
238
+ messages = Storage.all.select { |message| message.matches?(query) }
239
+ selected = messages.find { |message| message.id == request.params["id"] }
240
+ base = base_path(request)
241
+ [200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store" }, [page(messages, selected, query, base)]]
242
+ end
243
+
244
+ def page(messages, selected, query, base)
245
+ <<~HTML
246
+ <!doctype html>
247
+ <html lang="en">
248
+ <head>
249
+ <meta charset="utf-8">
250
+ <meta name="viewport" content="width=device-width, initial-scale=1">
251
+ <title>Rails Mailer Sandbox</title>
252
+ <style>#{styles}</style>
253
+ </head>
254
+ <body data-count="#{Storage.count}">
255
+ <aside class="rail">
256
+ <div class="brand"><span class="brand-mark">R</span><span>Rails Mailer Sandbox</span></div>
257
+ <nav><a class="nav-active" href="#{base}"><span>▣</span> Email Testing</a></nav>
258
+ <div class="rail-foot">Development only<br><strong>v#{VERSION}</strong></div>
259
+ </aside>
260
+ <section class="inbox #{selected ? "has-selection" : ""}">
261
+ <header class="inbox-head">
262
+ <div><span class="eyebrow">MY INBOX</span><h1>Captured emails <b>#{Storage.count}</b></h1></div>
263
+ <form method="post" action="#{base}/clear" onsubmit="return confirm('Delete all captured emails?')">
264
+ <button class="icon-button danger" title="Clear inbox" aria-label="Clear inbox">⌫</button>
265
+ </form>
266
+ </header>
267
+ <form class="search" method="get" action="#{base}">
268
+ <span>⌕</span><input type="search" name="q" value="#{h(query)}" placeholder="Search subject, sender, recipient..." aria-label="Search emails">
269
+ </form>
270
+ <div class="list" id="message-list">
271
+ #{message_list(messages, selected, query, base)}
272
+ </div>
273
+ </section>
274
+ <main class="viewer">
275
+ #{selected ? message_view(selected, base) : empty_state(query)}
276
+ </main>
277
+ <script>#{scripts(base)}</script>
278
+ </body>
279
+ </html>
280
+ HTML
281
+ end
282
+
283
+ def message_list(messages, selected, query, base)
284
+ return %(<div class="empty-list"><strong>No emails found</strong><span>#{query.empty? ? "Send an email from your app." : "Try another search."}</span></div>) if messages.empty?
285
+
286
+ messages.map do |message|
287
+ params = "?id=#{Rack::Utils.escape(message.id)}"
288
+ params += "&q=#{Rack::Utils.escape(query)}" unless query.empty?
289
+ <<~HTML
290
+ <a class="message #{"active" if message == selected}" href="#{base}#{params}">
291
+ <span class="avatar">#{h(initials(message.from.first))}</span>
292
+ <span class="message-copy">
293
+ <span class="message-line"><strong>#{h(message.from.first || "Unknown sender")}</strong><time>#{h(short_time(message.date))}</time></span>
294
+ <span class="subject">#{h(message.subject.empty? ? "(no subject)" : message.subject)}</span>
295
+ <span class="preview">#{h(message.preview)}</span>
296
+ <span class="recipient">to #{h(message.to.join(", "))}</span>
297
+ </span>
298
+ </a>
299
+ HTML
300
+ end.join
301
+ end
302
+
303
+ def message_view(message, base)
304
+ <<~HTML
305
+ <header class="viewer-head">
306
+ <a class="back" href="#{base}" aria-label="Back to inbox">‹</a>
307
+ <div class="title-block"><span class="eyebrow">MESSAGE PREVIEW</span><h2>#{h(message.subject.empty? ? "(no subject)" : message.subject)}</h2></div>
308
+ <form method="post" action="#{base}/messages/#{message.id}/delete" onsubmit="return confirm('Delete this email?')">
309
+ <button class="delete-button">Delete</button>
310
+ </form>
311
+ </header>
312
+ <section class="envelope">
313
+ <span class="avatar large">#{h(initials(message.from.first))}</span>
314
+ <div class="envelope-main">
315
+ <strong>#{h(message.from.join(", "))}</strong>
316
+ <button class="details-toggle" type="button" aria-expanded="false" onclick="toggleDetails(this)">to #{h(message.to.join(", "))}⌄</button>
317
+ <div class="details" hidden>
318
+ #{detail_row("From", message.from)}#{detail_row("To", message.to)}#{detail_row("Cc", message.cc)}#{detail_row("Bcc", message.bcc)}
319
+ <div><dt>Date</dt><dd>#{h(message.date.to_s)}</dd></div><div><dt>Size</dt><dd>#{human_size(message.size)}</dd></div>
320
+ </div>
321
+ </div>
322
+ <time>#{h(message.date.strftime("%b %-d, %Y, %H:%M"))}</time>
323
+ </section>
324
+ #{attachments(message, base)}
325
+ <nav class="tabs" aria-label="Message format">
326
+ <button class="tab active" data-pane="html">HTML</button>
327
+ <button class="tab" data-pane="text">Text</button>
328
+ <button class="tab" data-pane="raw">Raw</button>
329
+ <span class="spacer"></span>
330
+ <button class="viewport active" data-width="100%" title="Desktop preview">▰</button>
331
+ <button class="viewport" data-width="768px" title="Tablet preview">▯</button>
332
+ <button class="viewport" data-width="390px" title="Mobile preview">▯</button>
333
+ </nav>
334
+ <section class="preview-area">
335
+ <div class="pane active" id="pane-html"><iframe title="Email HTML preview" sandbox="allow-popups" src="#{base}/messages/#{message.id}/html"></iframe></div>
336
+ <pre class="pane source" id="pane-text">#{h(message.text_part.to_s)}</pre>
337
+ <pre class="pane source" id="pane-raw">#{h(message.raw_source.to_s)}</pre>
338
+ </section>
339
+ HTML
340
+ end
341
+
342
+ def attachments(message, base)
343
+ return "" if message.attachments.empty?
344
+
345
+ links = message.attachments.each_with_index.map do |attachment, index|
346
+ %(<a href="#{base}/messages/#{message.id}/attachment/#{index}"><span>⇩</span>#{h(attachment[:filename])}<small>#{human_size(attachment[:body].bytesize)}</small></a>)
347
+ end.join
348
+ %(<section class="attachments"><strong>#{message.attachments.size} attachment#{"s" unless message.attachments.size == 1}</strong><div>#{links}</div></section>)
349
+ end
350
+
351
+ def empty_state(query)
352
+ title = query.empty? ? "Inbox ready" : "No matching messages"
353
+ copy = query.empty? ? "Emails sent through ActionMailer appear here instantly." : "Clear search or send another test email."
354
+ %(<div class="empty-view"><span>✉</span><h2>#{title}</h2><p>#{copy}</p></div>)
355
+ end
356
+
357
+ def detail_row(label, values)
358
+ return "" if values.empty?
359
+ %(<div><dt>#{label}</dt><dd>#{h(values.join(", "))}</dd></div>)
360
+ end
361
+
362
+ def h(value)
363
+ CGI.escapeHTML(value.to_s)
364
+ end
365
+
366
+ def initials(address)
367
+ address.to_s.split(/[@._-]/).first(2).map { |part| part[0] }.join.upcase
368
+ end
369
+
370
+ def short_time(date)
371
+ date.to_date == Time.now.to_date ? date.strftime("%H:%M") : date.strftime("%b %-d")
372
+ end
373
+
374
+ def human_size(bytes)
375
+ return "#{bytes} B" if bytes < 1024
376
+ return format("%.1f KB", bytes / 1024.0) if bytes < 1_048_576
377
+ format("%.1f MB", bytes / 1_048_576.0)
378
+ end
379
+
380
+ def base_path(request)
381
+ request.script_name.sub(%r{/$}, "")
382
+ end
383
+
384
+ def not_found
385
+ [404, { "content-type" => "text/plain; charset=utf-8" }, ["Not Found"]]
386
+ end
387
+
388
+ def scripts(base)
389
+ <<~JS
390
+ function toggleDetails(button) {
391
+ const details = button.nextElementSibling;
392
+ details.hidden = !details.hidden;
393
+ button.setAttribute('aria-expanded', String(!details.hidden));
394
+ }
395
+ document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => {
396
+ document.querySelectorAll('.tab,.pane').forEach((node) => node.classList.remove('active'));
397
+ button.classList.add('active');
398
+ document.getElementById('pane-' + button.dataset.pane).classList.add('active');
399
+ }));
400
+ document.querySelectorAll('.viewport').forEach((button) => button.addEventListener('click', () => {
401
+ document.querySelectorAll('.viewport').forEach((node) => node.classList.remove('active'));
402
+ button.classList.add('active');
403
+ document.querySelector('iframe').style.maxWidth = button.dataset.width;
404
+ }));
405
+ setInterval(async () => {
406
+ if (document.querySelector('input[name=q]').value) return;
407
+ try {
408
+ const response = await fetch('#{base}/messages.json', { headers: { accept: 'application/json' } });
409
+ const messages = await response.json();
410
+ if (messages.length !== Number(document.body.dataset.count)) location.reload();
411
+ } catch (_) {}
412
+ }, 3000);
413
+ JS
414
+ end
415
+
416
+ def styles
417
+ <<~CSS
418
+ :root{--ink:#17223b;--muted:#718096;--line:#e6e9f0;--purple:#6757d9;--purple-soft:#f0edff;--surface:#fff;--canvas:#f6f7fb}*{box-sizing:border-box}html,body{height:100%;margin:0}body{display:grid;grid-template-columns:216px 390px minmax(0,1fr);overflow:hidden;color:var(--ink);background:var(--canvas);font:14px Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input{font:inherit}.rail{display:flex;flex-direction:column;padding:24px 16px;background:#17152b;color:#cac8dc}.brand{display:flex;align-items:center;gap:11px;padding:0 10px 28px;color:#fff;font-size:16px;font-weight:750}.brand-mark{display:grid;width:30px;height:30px;place-items:center;border-radius:9px;background:linear-gradient(145deg,#8b7cf6,#5b4bc5);box-shadow:0 7px 18px #0a0919}.rail nav a{display:flex;align-items:center;gap:11px;padding:11px 12px;border-radius:8px;color:#aaa8be;text-decoration:none}.rail nav a.nav-active{color:#fff;background:#2b2846}.rail-foot{margin-top:auto;padding:12px;color:#7f7b99;font-size:11px;line-height:1.7}.rail-foot strong{color:#aaa8be}.inbox{display:flex;min-width:0;flex-direction:column;border-right:1px solid var(--line);background:var(--surface)}.inbox-head,.viewer-head{display:flex;min-height:90px;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--line)}.eyebrow{color:#949aad;font-size:10px;font-weight:800;letter-spacing:.12em}.inbox h1{margin:5px 0 0;font-size:19px}.inbox h1 b{display:inline-grid;min-width:22px;height:22px;place-items:center;margin-left:5px;border-radius:20px;background:var(--purple-soft);color:var(--purple);font-size:11px}.icon-button,.delete-button{border:1px solid var(--line);border-radius:7px;background:#fff;color:#7d8495;cursor:pointer}.icon-button{width:34px;height:34px}.danger:hover,.delete-button:hover{border-color:#f4b8bd;color:#c43d4d;background:#fff7f8}.search{display:flex;align-items:center;gap:8px;margin:14px 16px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f8f9fc;color:#9399a8}.search:focus-within{border-color:#a49aec;box-shadow:0 0 0 3px var(--purple-soft)}.search input{width:100%;border:0;outline:0;background:transparent;color:var(--ink)}.list{overflow:auto}.message{display:flex;gap:12px;padding:15px 16px;border-top:1px solid #f0f1f5;color:inherit;text-decoration:none}.message:hover{background:#fafaff}.message.active{background:var(--purple-soft);box-shadow:inset 3px 0 var(--purple)}.avatar{display:grid;width:36px;height:36px;flex:0 0 auto;place-items:center;border-radius:50%;background:#e6e2ff;color:#5c4fc3;font-size:11px;font-weight:800}.avatar.large{width:42px;height:42px}.message-copy{min-width:0;flex:1}.message-line{display:flex;justify-content:space-between;gap:8px}.message-line strong,.subject,.preview,.recipient{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.message-line strong{font-size:13px}.message-line time,.recipient{color:#9ba1af;font-size:11px}.subject{margin-top:4px;font-weight:650}.preview{margin-top:4px;color:#767e90;font-size:12px}.recipient{margin-top:5px}.empty-list,.empty-view{display:flex;align-items:center;justify-content:center;flex-direction:column;color:var(--muted)}.empty-list{gap:6px;padding:60px 16px}.empty-list span{font-size:12px}.viewer{display:flex;min-width:0;flex-direction:column;overflow:hidden}.viewer-head{background:#fff}.title-block{min-width:0;flex:1}.viewer-head h2{overflow:hidden;margin:5px 25px 0 0;text-overflow:ellipsis;white-space:nowrap;font-size:20px}.delete-button{padding:8px 13px}.back{display:none;color:var(--ink);font-size:32px;text-decoration:none}.envelope{display:flex;align-items:flex-start;gap:12px;padding:17px 24px;border-bottom:1px solid var(--line);background:#fff}.envelope-main{min-width:0;flex:1}.envelope-main>strong{display:block}.details-toggle{padding:3px 0;border:0;background:none;color:var(--muted);cursor:pointer}.envelope>time{color:#969cab;font-size:11px}.details{max-width:650px;margin-top:10px;padding:10px 12px;border-radius:7px;background:#f7f8fb;font-size:12px}.details div{display:grid;grid-template-columns:50px 1fr;padding:3px}.details dt{color:var(--muted)}.details dd{overflow-wrap:anywhere;margin:0}.attachments{padding:12px 24px;border-bottom:1px solid var(--line);background:#fff;font-size:12px}.attachments>div{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px}.attachments a{display:flex;align-items:center;gap:6px;padding:7px 10px;border:1px solid var(--line);border-radius:6px;color:var(--ink);text-decoration:none}.attachments a:hover{border-color:#b8b0ed;background:var(--purple-soft)}.attachments small{color:var(--muted)}.tabs{display:flex;align-items:center;height:48px;padding:0 20px;border-bottom:1px solid var(--line);background:#fff}.tab,.viewport{height:100%;padding:0 13px;border:0;border-bottom:2px solid transparent;background:none;color:#7d8495;cursor:pointer}.tab.active{border-color:var(--purple);color:var(--purple);font-weight:700}.spacer{flex:1}.viewport{height:31px;padding:0 8px;border-radius:5px}.viewport.active{background:var(--purple-soft);color:var(--purple)}.preview-area{min-height:0;flex:1;overflow:hidden;padding:18px;background:#eef0f5}.pane{display:none;width:100%;height:100%;margin:0}.pane.active{display:block}.pane iframe{display:block;width:100%;height:100%;margin:auto;border:1px solid #dfe2e9;border-radius:4px;background:#fff;box-shadow:0 5px 20px #28345112;transition:max-width .2s}.source{overflow:auto;padding:20px;border:1px solid var(--line);border-radius:5px;background:#fff;white-space:pre-wrap;word-break:break-word;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.empty-view{height:100%}.empty-view>span{display:grid;width:72px;height:72px;place-items:center;border-radius:50%;background:var(--purple-soft);color:var(--purple);font-size:30px}.empty-view h2{margin:20px 0 6px}.empty-view p{margin:0}.empty-view .back{display:none}@media(max-width:900px){body{grid-template-columns:72px 330px minmax(0,1fr)}.rail{padding:24px 10px}.brand span:last-child,.rail nav a{font-size:0}.brand{padding:0 10px 28px}.rail nav a span{font-size:16px}.rail-foot{display:none}}@media(max-width:680px){body{display:block}.rail{display:none}.inbox{height:100%;border:0}.inbox.has-selection{display:none}.viewer{height:100%}.viewer-head{min-height:72px;padding:12px 15px}.back{display:block;margin-right:12px}.envelope{padding:14px 16px}.envelope>time{display:none}.tabs{padding:0 8px}.viewport{display:none}.preview-area{padding:8px}.title-block .eyebrow{display:none}.viewer-head h2{margin-top:0;font-size:16px}}
419
+ CSS
420
+ end
421
+ end
422
+ end
423
+
424
+ if defined?(Rails::Railtie)
425
+ module RailsMailerSandbox
426
+ class Railtie < Rails::Railtie
427
+ rake_tasks do
428
+ load File.expand_path("tasks/rails_mailer_sandbox.rake", __dir__)
429
+ end
430
+
431
+ initializer "rails_mailer_sandbox.add_delivery_method" do
432
+ ActiveSupport.on_load(:action_mailer) do
433
+ ActionMailer::Base.add_delivery_method :rails_mailer_sandbox, RailsMailerSandbox::DeliveryMethod
434
+ end
435
+ end
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "../rails_mailer_sandbox/setup"
2
+
3
+ namespace :rails_mailer_sandbox do
4
+ desc "Configure development email capture and mount Rails Mailer Sandbox UI"
5
+ task :setup do
6
+ RailsMailerSandbox::Setup.run(Rails.root)
7
+ puts "Rails Mailer Sandbox ready. Restart Rails, then open /rails_mailer_sandbox."
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-mailer-sandbox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - azmimuwahid
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: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mail
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.8'
40
+ description: Intercepts ActionMailer emails in development and provides mountable
41
+ Web UI to preview them.
42
+ email:
43
+ - azmimuwahid@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CONTRIBUTING.md
49
+ - MIT-LICENSE
50
+ - README.md
51
+ - lib/rails-mailer-sandbox.rb
52
+ - lib/rails_mailer_sandbox.rb
53
+ - lib/rails_mailer_sandbox/setup.rb
54
+ - lib/tasks/rails_mailer_sandbox.rake
55
+ homepage: https://github.com/azmi2409/rails-mailer-sandbox
56
+ licenses:
57
+ - MIT
58
+ metadata: {}
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 3.0.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 4.0.4
74
+ specification_version: 4
75
+ summary: Local email sandbox with Web UI for Rails development
76
+ test_files: []