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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +47 -28
- data/lib/rails_mailer_sandbox.rb +39 -17
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 30219e130ba9277194b1a2d50207f24bf34f4e495e42643121ae6d4cc7226740
|
|
4
|
+
data.tar.gz: 89b3f2f7b139d429dcc9dda9defce2ebad882d6d28cd398d28d9204f4917aed8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
4
|
-
|
|
3
|
+
[](https://github.com/azmi2409/rails-mailer-sandbox/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/rails-mailer-sandbox)
|
|
5
|
+
[](MIT-LICENSE)
|
|
5
6
|
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
-
|
|
14
|
-
-
|
|
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
|
|
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
|
-
##
|
|
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"
|
|
31
|
+
gem "rails-mailer-sandbox"
|
|
27
32
|
end
|
|
28
33
|
```
|
|
29
34
|
|
|
30
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
65
|
+
## 💾 Where Emails Live
|
|
59
66
|
|
|
60
67
|
Default path: `Rails.root/tmp/rails_mailer_sandbox`.
|
|
61
68
|
|
|
62
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. 🎉
|
data/lib/rails_mailer_sandbox.rb
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 '
|
|
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
|
-
|
|
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
|
|
230
|
-
|
|
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"
|
|
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}/
|
|
409
|
-
const
|
|
410
|
-
if (
|
|
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.
|
|
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
|