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