rails-mailer-sandbox 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +47 -28
  4. data/lib/rails_mailer_sandbox.rb +39 -17
  5. metadata +30 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b3769b564df7ce2bf2278546029a59a92ad70ceec2e074ab4550abecbd4055e
4
- data.tar.gz: ac6f6d14a1e60deadfc5388c42426d0d79e942fe3ba65986199391589c4be47d
3
+ metadata.gz: 30219e130ba9277194b1a2d50207f24bf34f4e495e42643121ae6d4cc7226740
4
+ data.tar.gz: 89b3f2f7b139d429dcc9dda9defce2ebad882d6d28cd398d28d9204f4917aed8
5
5
  SHA512:
6
- metadata.gz: 8744e34ffdf4cade29d895b9279c1f38aefd55e0beea7d50ef74387d5418b278c011116b9b16c1ccc70bb15cc7840bb8e5d0aad2e8ae51c8dff8e2aca3eea761
7
- data.tar.gz: ba697866c07972be8adafcae6de50690cd0c653d3f44f24583ab0e77587070ce84e740f1ce3f6f4777aa55f2b531d152819c0c5032c9f766f0d83dbce67cffe3
6
+ metadata.gz: 1a3e299b55a9b0182955b80fcb319df145703ce129c8e1faac48e1886c9d724e4555073c665d8a8a5ed8e0388feaa4125cc164071a8994bbd1c2ec8f9cf5abbb
7
+ data.tar.gz: b84defec8928f86cee0e6a6806a831c044f9f9395e80f99ea61f8f92b99f5b66ae53d91226e1c5d45f74fd9da11af1ce71ef9e74d4772274b1e40c3fe0b01bea
data/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to Rails Mailer Sandbox are documented here.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions
6
+ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.1] - 2026-09-13
9
+
10
+ ### Added
11
+
12
+ - Inline MIME image rendering through embedded `data:` URLs
13
+ - Safe new-tab behavior for links inside HTML previews
14
+ - RFC 5987 UTF-8 attachment download filenames
15
+ - Lightweight `/status.json` endpoint for inbox polling
16
+ - Recipient labels for Bcc-only and undisclosed-recipient messages
17
+ - Root `Gemfile` and GitHub Actions CI for Ruby 3.0 through 3.3
18
+ - Explicit `base64` dependency for Ruby 3.4 compatibility
19
+ - Regression tests for complex HTML-only and multipart emails
20
+
21
+ ### Fixed
22
+
23
+ - CSS and script content leaking into generated plain-text previews
24
+ - Search ignoring body content after first 140 characters
25
+ - Inline `cid:` images appearing broken
26
+ - Links without `target="_blank"` doing nothing inside preview
27
+ - Every polling request parsing all stored `.eml` files
28
+
29
+ ### Security
30
+
31
+ - Preview links escape sandbox only into new tabs; scripts, forms, remote resources,
32
+ and top-level navigation remain blocked
33
+
34
+ ## [0.2.0] - 2026-09-13
35
+
36
+ ### Added
37
+
38
+ - Initial RubyGems release
39
+ - Action Mailer delivery method
40
+ - Persistent filesystem-backed `.eml` storage
41
+ - Mountable Rails Web UI with search, HTML/text/raw views, responsive preview widths,
42
+ attachments, automatic refresh, and message deletion
43
+ - `rails_mailer_sandbox:setup` Rake task
44
+
45
+ [0.2.1]: https://github.com/azmi2409/rails-mailer-sandbox/compare/v0.2.0...v0.2.1
46
+ [0.2.0]: https://github.com/azmi2409/rails-mailer-sandbox/releases/tag/v0.2.0
data/README.md CHANGED
@@ -1,47 +1,54 @@
1
- # Rails Mailer Sandbox
1
+ # Rails Mailer Sandbox 📬
2
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.
3
+ [![CI](https://github.com/azmi2409/rails-mailer-sandbox/actions/workflows/ci.yml/badge.svg)](https://github.com/azmi2409/rails-mailer-sandbox/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/rails-mailer-sandbox.svg)](https://rubygems.org/gems/rails-mailer-sandbox)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE)
5
6
 
6
- ## Features
7
+ Catch Rails emails locally before they escape into the wild. Rails Mailer Sandbox stores
8
+ real `.eml` messages and gives you a Mailtrap-inspired inbox right inside your Rails app.
9
+ No SMTP server, database, account, or monthly bill needed.
7
10
 
8
- - Persistent MIME message storage across Rails restarts
9
- - Search by subject, sender, recipient, or body preview
11
+ ## What You Get
12
+
13
+ - Persistent MIME messages across Rails restarts
14
+ - Search by subject, sender, recipient, or full body
10
15
  - HTML, plain-text, and raw-source views
11
16
  - Desktop, tablet, and mobile preview widths
12
17
  - From, To, Cc, Bcc, date, and message-size details
13
- - Attachment downloads
14
- - Automatic inbox refresh
18
+ - Inline `cid:` image previews and attachment downloads
19
+ - UTF-8 attachment filenames
20
+ - Automatic inbox refresh without repeatedly parsing every email
15
21
  - Individual and bulk deletion
16
- - Sandboxed HTML previews with remote resources and scripts blocked
22
+ - Sandboxed HTML previews with scripts and remote resources blocked
17
23
  - Filesystem locking and atomic writes for Rails and background workers
18
- - No database or external service
19
24
 
20
- ## Installation
25
+ ## 🚀 Get Started
21
26
 
22
27
  Add gem to development group:
23
28
 
24
29
  ```ruby
25
30
  group :development do
26
- gem "rails-mailer-sandbox", github: "azmi2409/rails-mailer-sandbox"
31
+ gem "rails-mailer-sandbox"
27
32
  end
28
33
  ```
29
34
 
30
- Then run:
35
+ Set it up:
31
36
 
32
37
  ```bash
33
38
  bundle install
34
39
  bundle exec rake rails_mailer_sandbox:setup
35
40
  ```
36
41
 
37
- Restart Rails and open <http://localhost:3000/rails_mailer_sandbox>.
42
+ Restart Rails, send any Action Mailer email, then visit:
43
+
44
+ 👉 <http://localhost:3000/rails_mailer_sandbox>
38
45
 
39
46
  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.
47
+ and mounts Web UI. Run task again safely; it will not overwrite edited initializer.
41
48
 
42
- ## Manual Setup
49
+ ## 🔧 Manual Setup
43
50
 
44
- Configure `config/environments/development.rb`:
51
+ Prefer doing it yourself? Add this to `config/environments/development.rb`:
45
52
 
46
53
  ```ruby
47
54
  config.action_mailer.delivery_method = :rails_mailer_sandbox
@@ -49,35 +56,47 @@ config.action_mailer.perform_deliveries = true
49
56
  config.action_mailer.raise_delivery_errors = true
50
57
  ```
51
58
 
52
- Mount Web UI in `config/routes.rb`:
59
+ Mount inbox in `config/routes.rb`:
53
60
 
54
61
  ```ruby
55
62
  mount RailsMailerSandbox::Server.new => "/rails_mailer_sandbox" if Rails.env.development?
56
63
  ```
57
64
 
58
- ## Storage
65
+ ## 💾 Where Emails Live
59
66
 
60
67
  Default path: `Rails.root/tmp/rails_mailer_sandbox`.
61
68
 
62
- Override path in initializer:
69
+ Want another location? Set it in initializer:
63
70
 
64
71
  ```ruby
65
72
  RailsMailerSandbox.storage_path = Rails.root.join("storage", "development_mail")
66
73
  ```
67
74
 
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.
75
+ Keep custom storage out of version control. `bin/rails tmp:clear` removes default inbox.
76
+ Filesystem storage targets local development on one host, not distributed deployments or
77
+ giant email archives.
71
78
 
72
- ## Development
79
+ ## 🧪 Development
73
80
 
74
81
  ```bash
75
- ruby -Ilib test/rails_mailer_sandbox_test.rb
82
+ git clone https://github.com/azmi2409/rails-mailer-sandbox.git
83
+ cd rails-mailer-sandbox
84
+ bundle install
85
+ bundle exec ruby -Ilib test/rails_mailer_sandbox_test.rb
76
86
  gem build rails-mailer-sandbox.gemspec
77
87
  ```
78
88
 
79
- See [CONTRIBUTING.md](CONTRIBUTING.md) before opening pull request.
89
+ CI runs tests and gem build on Ruby 3.0, 3.1, 3.2, and 3.3.
90
+ Found a bug or have a neat focused improvement? Read [CONTRIBUTING.md](CONTRIBUTING.md),
91
+ then open an issue or pull request. 🙌
92
+
93
+ Release notes live in [CHANGELOG.md](CHANGELOG.md). 📝
94
+
95
+ ## 🔒 One Important Rule
96
+
97
+ Use this gem in development only. Captured emails may contain reset links, personal data,
98
+ or other secrets. Never expose mounted inbox publicly or enable it in production.
80
99
 
81
- ## License
100
+ ## 📄 License
82
101
 
83
- [MIT](MIT-LICENSE)
102
+ [MIT](MIT-LICENSE). Build useful things and test your emails. 🎉
@@ -8,9 +8,11 @@ require "time"
8
8
  require "date"
9
9
  require "fileutils"
10
10
  require "mail"
11
+ require "base64"
12
+ require "uri"
11
13
 
12
14
  module RailsMailerSandbox
13
- VERSION = "0.2.0"
15
+ VERSION = "0.2.1"
14
16
 
15
17
  class << self
16
18
  attr_writer :storage_path
@@ -51,29 +53,48 @@ module RailsMailerSandbox
51
53
  end
52
54
 
53
55
  def matches?(query)
54
- query.empty? || [subject, *from, *recipients, preview].any? { |value| value.downcase.include?(query) }
56
+ query.empty? || [subject, *from, *recipients, text_part.to_s].any? { |value| value.downcase.include?(query) }
55
57
  end
56
58
 
57
59
  private
58
60
 
59
61
  def extract_parts(mail)
60
62
  parts = mail.multipart? ? mail.all_parts : [mail]
63
+ inline_parts = {}
61
64
  parts.each do |part|
62
65
  if part.respond_to?(:attachment?) && part.attachment?
66
+ content_id = part.content_id.to_s.delete("<>")
63
67
  @attachments << {
64
68
  filename: part.filename.to_s,
65
69
  content_type: part.mime_type.to_s,
66
70
  body: part.body.decoded
67
71
  }
72
+ inline_parts[content_id] = part unless content_id.empty?
73
+ inline_parts[part.filename.to_s] = part unless part.filename.to_s.empty?
68
74
  elsif part.mime_type == "text/html" && !@html_part
69
75
  @html_part = part.body.decoded
70
76
  elsif part.mime_type == "text/plain" && !@text_part
71
77
  @text_part = part.body.decoded
72
78
  end
73
79
  end
74
- @text_part ||= @html_part&.gsub(/<[^>]*>/, " ")&.gsub(/\s+/, " ")&.strip
80
+ inline_parts.each do |content_id, part|
81
+ data_uri = "data:#{part.mime_type};base64,#{Base64.strict_encode64(part.body.decoded)}"
82
+ @html_part&.gsub!(/cid:#{Regexp.escape(content_id)}/i, data_uri)
83
+ end
84
+ @html_part = with_external_link_target(@html_part) if @html_part
85
+ @text_part ||= @html_part&.gsub(/<style\b[^>]*>.*?<\/style>/im, " ")
86
+ &.gsub(/<script\b[^>]*>.*?<\/script>/im, " ")
87
+ &.gsub(/<[^>]*>/, " ")&.then { |text| CGI.unescapeHTML(text) }&.gsub(/\s+/, " ")&.strip
75
88
  @html_part ||= "<pre style=\"white-space:pre-wrap\">#{CGI.escapeHTML(@text_part.to_s)}</pre>"
76
89
  end
90
+
91
+ def with_external_link_target(html)
92
+ if html.match?(/<head\b[^>]*>/i)
93
+ html.sub(/<head\b[^>]*>/i) { |head| "#{head}<base target=\"_blank\">" }
94
+ else
95
+ "<base target=\"_blank\">#{html}"
96
+ end
97
+ end
77
98
  end
78
99
 
79
100
  class Storage
@@ -189,7 +210,7 @@ module RailsMailerSandbox
189
210
  end
190
211
 
191
212
  return render_index(request) if method == "GET" && ["", "/"].include?(path)
192
- return render_messages_json if method == "GET" && path == "/messages.json"
213
+ return render_status_json if method == "GET" && path == "/status.json"
193
214
  return render_part(Regexp.last_match(1), :html_part, "text/html; charset=utf-8") if method == "GET" && path =~ %r{\A/messages/([^/]+)/html\z}
194
215
  return render_part(Regexp.last_match(1), :raw_source, "text/plain; charset=utf-8") if method == "GET" && path =~ %r{\A/messages/([^/]+)/raw\z}
195
216
  return render_attachment(Regexp.last_match(1), Regexp.last_match(2).to_i) if method == "GET" && path =~ %r{\A/messages/([^/]+)/attachment/(\d+)\z}
@@ -209,7 +230,7 @@ module RailsMailerSandbox
209
230
  return not_found unless message
210
231
 
211
232
  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
233
+ headers["content-security-policy"] = "sandbox allow-popups allow-popups-to-escape-sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'self'; form-action 'none'" if field == :html_part
213
234
  [200, headers, [message.public_send(field).to_s]]
214
235
  end
215
236
 
@@ -217,20 +238,19 @@ module RailsMailerSandbox
217
238
  attachment = Storage.find(id)&.attachments&.[](index)
218
239
  return not_found unless attachment
219
240
 
220
- filename = attachment[:filename].encode("US-ASCII", invalid: :replace, undef: :replace).gsub(/[\x00-\x1f\x7f"\\]/, "_")
241
+ original_filename = attachment[:filename].encode("UTF-8", invalid: :replace, undef: :replace).gsub(/[\x00-\x1f\x7f]/, "_")
242
+ filename = original_filename.encode("US-ASCII", invalid: :replace, undef: :replace, replace: "_").gsub(/["\\]/, "_")
243
+ encoded_filename = URI.encode_www_form_component(original_filename).tr("+", "%20")
221
244
  type = attachment[:content_type].match?(/\A[-\w.+]+\/[-\w.+]+\z/) ? attachment[:content_type] : "application/octet-stream"
222
245
  [200, {
223
246
  "content-type" => type,
224
- "content-disposition" => %(attachment; filename="#{filename}"),
247
+ "content-disposition" => %(attachment; filename="#{filename}"; filename*=UTF-8''#{encoded_filename}),
225
248
  "content-length" => attachment[:body].bytesize.to_s
226
249
  }, [attachment[:body]]]
227
250
  end
228
251
 
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)]]
252
+ def render_status_json
253
+ [200, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [JSON.generate(count: Storage.count)]]
234
254
  end
235
255
 
236
256
  def render_index(request)
@@ -286,6 +306,8 @@ module RailsMailerSandbox
286
306
  messages.map do |message|
287
307
  params = "?id=#{Rack::Utils.escape(message.id)}"
288
308
  params += "&q=#{Rack::Utils.escape(query)}" unless query.empty?
309
+ recipient = message.to.first || message.recipients.first
310
+ recipient_label = recipient ? "to #{recipient}" : "undisclosed recipients"
289
311
  <<~HTML
290
312
  <a class="message #{"active" if message == selected}" href="#{base}#{params}">
291
313
  <span class="avatar">#{h(initials(message.from.first))}</span>
@@ -293,7 +315,7 @@ module RailsMailerSandbox
293
315
  <span class="message-line"><strong>#{h(message.from.first || "Unknown sender")}</strong><time>#{h(short_time(message.date))}</time></span>
294
316
  <span class="subject">#{h(message.subject.empty? ? "(no subject)" : message.subject)}</span>
295
317
  <span class="preview">#{h(message.preview)}</span>
296
- <span class="recipient">to #{h(message.to.join(", "))}</span>
318
+ <span class="recipient">#{h(recipient_label)}</span>
297
319
  </span>
298
320
  </a>
299
321
  HTML
@@ -332,7 +354,7 @@ module RailsMailerSandbox
332
354
  <button class="viewport" data-width="390px" title="Mobile preview">▯</button>
333
355
  </nav>
334
356
  <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>
357
+ <div class="pane active" id="pane-html"><iframe title="Email HTML preview" sandbox="allow-popups allow-popups-to-escape-sandbox" src="#{base}/messages/#{message.id}/html"></iframe></div>
336
358
  <pre class="pane source" id="pane-text">#{h(message.text_part.to_s)}</pre>
337
359
  <pre class="pane source" id="pane-raw">#{h(message.raw_source.to_s)}</pre>
338
360
  </section>
@@ -405,9 +427,9 @@ module RailsMailerSandbox
405
427
  setInterval(async () => {
406
428
  if (document.querySelector('input[name=q]').value) return;
407
429
  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();
430
+ const response = await fetch('#{base}/status.json', { headers: { accept: 'application/json' } });
431
+ const status = await response.json();
432
+ if (status.count !== Number(document.body.dataset.count)) location.reload();
411
433
  } catch (_) {}
412
434
  }, 3000);
413
435
  JS
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-mailer-sandbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - azmimuwahid
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: base64
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: test-unit
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
40
68
  description: Intercepts ActionMailer emails in development and provides mountable
41
69
  Web UI to preview them.
42
70
  email:
@@ -45,6 +73,7 @@ executables: []
45
73
  extensions: []
46
74
  extra_rdoc_files: []
47
75
  files:
76
+ - CHANGELOG.md
48
77
  - CONTRIBUTING.md
49
78
  - MIT-LICENSE
50
79
  - README.md