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 +7 -0
- data/CONTRIBUTING.md +43 -0
- data/MIT-LICENSE +21 -0
- data/README.md +83 -0
- data/lib/rails-mailer-sandbox.rb +3 -0
- data/lib/rails_mailer_sandbox/setup.rb +32 -0
- data/lib/rails_mailer_sandbox.rb +438 -0
- data/lib/tasks/rails_mailer_sandbox.rake +9 -0
- metadata +76 -0
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,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: []
|