localmail 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/CHANGELOG.md +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +147 -0
- data/Rakefile +6 -0
- data/app/controllers/localmail/application_controller.rb +24 -0
- data/app/controllers/localmail/messages_controller.rb +35 -0
- data/app/views/layouts/localmail/application.html.erb +24 -0
- data/app/views/localmail/_styles.css.erb +86 -0
- data/app/views/localmail/messages/index.html.erb +47 -0
- data/app/views/localmail/messages/show.html.erb +88 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261001000000_create_localmail_messages.rb +9 -0
- data/lib/localmail/capture.rb +31 -0
- data/lib/localmail/configuration.rb +32 -0
- data/lib/localmail/delivery_method.rb +19 -0
- data/lib/localmail/engine.rb +13 -0
- data/lib/localmail/message.rb +57 -0
- data/lib/localmail/store.rb +12 -0
- data/lib/localmail/stores/active_record.rb +64 -0
- data/lib/localmail/stores/redis.rb +96 -0
- data/lib/localmail/version.rb +3 -0
- data/lib/localmail.rb +62 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3a4368d18e29898c3d0da732096bdcbfca22bae05aba4c0a39657a194349e125
|
|
4
|
+
data.tar.gz: 23058480820db07e13055fffb48caa10b0da2861a70afa1689e1101024f76d1a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5e20872b27d16760ae4f8b2e4723b93c92c5d695e9f73f16676cb3598028c98c56758850eed3b584a3572bd74000f5828ba9018d4f9580631fa1a8a5b0a4acac
|
|
7
|
+
data.tar.gz: 7ce8b3f9eeefa367f14e314cc2fb3f162c19e830cee7d1edbe013b37c5e4b9170d429cfef370b14c922bb8aaec7963d7c89b56bffec85f0576f8ca3d843464ee
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.2.0]
|
|
6
|
+
|
|
7
|
+
- **Pluggable storage.** `config.store` takes `:active_record` (new default), `:redis`, or a
|
|
8
|
+
store object. The ActiveRecord store needs one table:
|
|
9
|
+
`bin/rails localmail:install:migrations && bin/rails db:migrate`.
|
|
10
|
+
- **Redis is now optional.** `redis` and `connection_pool` are no longer dependencies. Add them
|
|
11
|
+
to your Gemfile and set `config.store = :redis` to keep the previous behaviour.
|
|
12
|
+
- A capture that fails to save is logged before the error is re-raised, so it is not lost
|
|
13
|
+
silently when `raise_delivery_errors` is off.
|
|
14
|
+
- `Localmail.redis` is gone. Use `Localmail.store.redis` with the Redis store.
|
|
15
|
+
|
|
16
|
+
## [0.1.0]
|
|
17
|
+
|
|
18
|
+
- `capture_in_localmail` opt-in macro, included into every mailer.
|
|
19
|
+
- Redis store bounded by TTL and a message cap, namespaced per environment.
|
|
20
|
+
- Mountable inbox with HTML, plain-text and source views and a desktop/mobile preview toggle.
|
|
21
|
+
- `Localmail.configure` for enabling, Redis, limits, namespace, parent controller and an authenticate hook.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright John Arnold
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Localmail
|
|
2
|
+
|
|
3
|
+
An inbox for email captured from deployed Rails environments.
|
|
4
|
+
|
|
5
|
+
On a review app or staging server, mail often cannot go anywhere useful: the provider is in
|
|
6
|
+
sandbox mode, or you do not want real customers receiving test sends. The usual tools
|
|
7
|
+
(mailcatcher, letter_opener_web) write to the local filesystem or a local SMTP port, so they
|
|
8
|
+
break as soon as mail is sent from a worker dyno or container and read from a web one.
|
|
9
|
+
|
|
10
|
+
Localmail keeps captured mail somewhere every process already shares (your database, or Redis
|
|
11
|
+
if you prefer) and serves it from a mountable inbox.
|
|
12
|
+
|
|
13
|
+
- **Opt-in per mailer action.** Only the actions you name are captured, and everything else
|
|
14
|
+
keeps your normal delivery method.
|
|
15
|
+
- **Bounded.** Messages expire after a TTL and the inbox keeps only the newest N, so capture
|
|
16
|
+
cannot fill a database or a Redis instance shared with Sidekiq.
|
|
17
|
+
- **Off by default.** Without `CAPTURE_EMAILS` (or `config.enabled`) nothing is diverted and the
|
|
18
|
+
inbox returns 404.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Gemfile
|
|
24
|
+
gem "localmail"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Captured mail is stored in your database by default, in one table:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bin/rails localmail:install:migrations
|
|
31
|
+
bin/rails db:migrate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Mount the inbox, behind your own authentication:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/routes.rb
|
|
38
|
+
authenticate :admin do # Devise; or use config.authenticate below
|
|
39
|
+
mount Localmail::Engine, at: "/mail"
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Turn it on in the environments that should capture:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
CAPTURE_EMAILS=true
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Choosing what to capture
|
|
50
|
+
|
|
51
|
+
Every mailer gets the `capture_in_localmail` macro:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
class AccountMailer < ApplicationMailer
|
|
55
|
+
capture_in_localmail :sign_in_link # just this action
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class DigestMailer < ApplicationMailer
|
|
59
|
+
capture_in_localmail # every action on this mailer
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Declare once per mailer. The delivery method is swapped per message at delivery time, so a
|
|
64
|
+
mailer delivered from a background job is decided when it sends.
|
|
65
|
+
|
|
66
|
+
## Storage
|
|
67
|
+
|
|
68
|
+
| Store | When | Needs |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `:active_record` (default) | Any app. Works with the Solid Queue/Cache stack and with no Redis at all | The `localmail_messages` table (above) |
|
|
71
|
+
| `:redis` | You already run Redis and would rather keep captured mail out of your database | `gem "redis"` and `gem "connection_pool"` in your Gemfile |
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
Localmail.configure do |config|
|
|
75
|
+
config.store = :redis
|
|
76
|
+
config.redis = -> { Redis.new(url: ENV["REDIS_URL"]) } # default: Redis.new, pooled
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
On Heroku Redis, whose certificates do not verify:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
config.redis = -> { Redis.new(ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE }) }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`config.store` also takes any object that responds to `save(mail)` (returning an id),
|
|
87
|
+
`all`, `find(id)`, `delete(id)` and `clear`, returning `Localmail::Message` objects.
|
|
88
|
+
|
|
89
|
+
If saving fails (the database or Redis is unreachable), Localmail logs
|
|
90
|
+
`Localmail: failed to capture "<subject>"` and re-raises. With `raise_delivery_errors` off,
|
|
91
|
+
ActionMailer then swallows the error, so the log line is your only sign that a message was
|
|
92
|
+
neither captured nor sent.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# config/initializers/localmail.rb
|
|
98
|
+
Localmail.configure do |config|
|
|
99
|
+
config.enabled = ENV["CAPTURE_EMAILS"] == "true" # default: CAPTURE_EMAILS, cast to boolean
|
|
100
|
+
config.store = :active_record # default
|
|
101
|
+
config.ttl = 3.days # default
|
|
102
|
+
config.max_messages = 50 # default
|
|
103
|
+
config.capture_in_test = false # default
|
|
104
|
+
config.parent_controller = "ActionController::Base" # default
|
|
105
|
+
config.authenticate = -> { authenticate_admin! } # default: nil
|
|
106
|
+
config.redis = nil # :redis store only
|
|
107
|
+
config.namespace = "localmail:my_app:#{Rails.env}" # :redis store only. Default: app name + environment
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Setting | What it does |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `enabled` | Boolean or callable. Unset, it reads `CAPTURE_EMAILS`. |
|
|
114
|
+
| `store` | `:active_record`, `:redis`, or a store object. |
|
|
115
|
+
| `ttl` | How long each message is kept. |
|
|
116
|
+
| `max_messages` | How many messages the inbox holds. The oldest roll off. |
|
|
117
|
+
| `capture_in_test` | Capture in `Rails.env.test?` too. Off, so mailer specs still see `ActionMailer::Base.deliveries`. |
|
|
118
|
+
| `parent_controller` | What the inbox controller inherits from. Set it to your own controller to pick up its authentication. Read once, when the controller loads. |
|
|
119
|
+
| `authenticate` | A block run in the inbox controller before every action, e.g. `-> { authenticate_admin! }`. |
|
|
120
|
+
| `redis` | `:redis` store only. A callable that builds a client (wrapped in a connection pool), or a ready-made client. |
|
|
121
|
+
| `namespace` | `:redis` store only. Key prefix, defaulting to the app's name and environment, so neither another app on the same Redis nor a test run can see or wipe your inbox. |
|
|
122
|
+
|
|
123
|
+
## Safety
|
|
124
|
+
|
|
125
|
+
**There is no environment guard.** Review apps usually run with `RAILS_ENV=production`, so a
|
|
126
|
+
`Rails.env.production?` check would block the case this exists for. The enable flag is the only
|
|
127
|
+
thing between production and a silent mail outage: a captured message looks exactly like a
|
|
128
|
+
delivered one in the logs. Keep `CAPTURE_EMAILS` unset in production, and weigh that every time
|
|
129
|
+
you add a mailer action to the capture list.
|
|
130
|
+
|
|
131
|
+
The inbox shows whole messages, including sign-in links and tokens. Always mount it behind
|
|
132
|
+
authentication.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
bundle install
|
|
138
|
+
bin/ci # RuboCop, gem audit, RSpec
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The suite runs on the dummy app's SQLite and needs nothing running. Start `redis-server` to
|
|
142
|
+
include the Redis store's examples; without it they are skipped with a warning (on CI, a
|
|
143
|
+
missing Redis fails the run instead).
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
# Base for the inbox. Inherits from the host's choice of controller so its own
|
|
3
|
+
# authentication and helpers apply, then runs the configured authenticate hook.
|
|
4
|
+
class ApplicationController < Localmail.config.parent_controller.constantize
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
before_action :require_enabled
|
|
8
|
+
before_action :authenticate_localmail
|
|
9
|
+
|
|
10
|
+
layout "localmail/application"
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Mounted but switched off is the same as not mounted.
|
|
15
|
+
def require_enabled
|
|
16
|
+
head :not_found unless Localmail.enabled?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def authenticate_localmail
|
|
20
|
+
hook = Localmail.config.authenticate
|
|
21
|
+
instance_exec(&hook) if hook
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
# The inbox: lists, shows and deletes captured messages.
|
|
3
|
+
class MessagesController < ApplicationController
|
|
4
|
+
before_action :set_messages, only: :index
|
|
5
|
+
before_action :set_message, only: :show
|
|
6
|
+
|
|
7
|
+
def index; end
|
|
8
|
+
|
|
9
|
+
def show; end
|
|
10
|
+
|
|
11
|
+
def destroy
|
|
12
|
+
Store.delete(params[:id])
|
|
13
|
+
|
|
14
|
+
redirect_to root_path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def destroy_all
|
|
18
|
+
Store.clear
|
|
19
|
+
|
|
20
|
+
redirect_to root_path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def set_messages
|
|
26
|
+
@messages = Store.all
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set_message
|
|
30
|
+
@message = Store.find(params[:id])
|
|
31
|
+
|
|
32
|
+
redirect_to root_path if @message.nil?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= content_for?(:title) ? "#{yield(:title)} | Localmail" : "Localmail" %></title>
|
|
5
|
+
<meta name="robots" content="noindex,nofollow">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
<%= tag.style render(partial: "localmail/styles", formats: :css), nonce: content_security_policy_nonce %>
|
|
10
|
+
<%= yield :head %>
|
|
11
|
+
</head>
|
|
12
|
+
<body class="lm-body">
|
|
13
|
+
<header class="lm-header">
|
|
14
|
+
<div class="lm-container lm-header-inner">
|
|
15
|
+
<%= link_to "Localmail", root_path, class: "lm-brand" %>
|
|
16
|
+
<p class="lm-muted lm-small">Captured mail - nothing is delivered</p>
|
|
17
|
+
</div>
|
|
18
|
+
</header>
|
|
19
|
+
|
|
20
|
+
<main>
|
|
21
|
+
<%= yield %>
|
|
22
|
+
</main>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--lm-black: #111;
|
|
3
|
+
--lm-text: #494949;
|
|
4
|
+
--lm-muted: #8c8c8c;
|
|
5
|
+
--lm-line: #eaeaea;
|
|
6
|
+
--lm-wash: #f9f9f9;
|
|
7
|
+
--lm-white: #fff;
|
|
8
|
+
--lm-danger: #c62828;
|
|
9
|
+
--lm-danger-wash: #fdecea;
|
|
10
|
+
--lm-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
11
|
+
}
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
.lm-body { margin: 0; min-height: 100vh; background: var(--lm-wash); color: var(--lm-text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
14
|
+
.lm-container { width: 100%; max-width: 72rem; margin: 0 auto; padding: 0 1rem; }
|
|
15
|
+
.lm-header { background: var(--lm-black); color: var(--lm-white); }
|
|
16
|
+
.lm-header-inner { display: flex; align-items: center; justify-content: space-between; padding-top: .75rem; padding-bottom: .75rem; }
|
|
17
|
+
.lm-brand { color: var(--lm-white); font-weight: 600; font-size: 1rem; text-decoration: none; }
|
|
18
|
+
.lm-muted { color: var(--lm-muted); }
|
|
19
|
+
.lm-small { font-size: .75rem; margin: 0; }
|
|
20
|
+
.lm-label { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--lm-muted); }
|
|
21
|
+
.lm-toolbar { background: var(--lm-white); border-bottom: 1px solid var(--lm-line); }
|
|
22
|
+
.lm-toolbar-inner { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; padding-top: .75rem; padding-bottom: .75rem; }
|
|
23
|
+
.lm-toolbar-actions { margin-left: auto; display: flex; align-items: center; gap: .5rem; }
|
|
24
|
+
.lm-toolbar-actions form { margin: 0; }
|
|
25
|
+
.lm-strong { font-weight: 600; color: var(--lm-black); }
|
|
26
|
+
.lm-link { color: var(--lm-black); font-weight: 500; text-decoration: none; }
|
|
27
|
+
.lm-link:hover { text-decoration: underline; }
|
|
28
|
+
.lm-button { display: inline-block; border: 1px solid var(--lm-line); border-radius: .5rem; background: var(--lm-white); color: var(--lm-black); padding: .5rem 1rem; font: inherit; font-weight: 500; text-decoration: none; cursor: pointer; }
|
|
29
|
+
.lm-button:hover { background: var(--lm-wash); }
|
|
30
|
+
.lm-button-danger { color: var(--lm-danger); }
|
|
31
|
+
.lm-button-danger:hover { background: var(--lm-danger-wash); }
|
|
32
|
+
.lm-page { padding-top: 2rem; padding-bottom: 2rem; }
|
|
33
|
+
.lm-card { overflow: hidden; background: var(--lm-white); border: 1px solid var(--lm-line); border-radius: .75rem; box-shadow: 0 1px 2px rgb(0 0 0 / .05); }
|
|
34
|
+
.lm-empty { max-width: 48rem; margin: 0 auto; padding: 1.5rem; text-align: center; }
|
|
35
|
+
.lm-empty h1 { margin: 0; font-size: 1.125rem; color: var(--lm-black); }
|
|
36
|
+
.lm-empty p { margin: .25rem auto 0; max-width: 28rem; color: var(--lm-muted); }
|
|
37
|
+
.lm-list { list-style: none; margin: 0; padding: 0; }
|
|
38
|
+
.lm-row { position: relative; display: flex; align-items: center; gap: 1rem; padding: 1rem 1.5rem; border-top: 1px solid var(--lm-line); }
|
|
39
|
+
.lm-row:first-child { border-top: 0; }
|
|
40
|
+
.lm-row:hover { background: var(--lm-wash); }
|
|
41
|
+
.lm-time { display: flex; flex-direction: column; flex-shrink: 0; width: 6rem; padding-right: 1rem; border-right: 1px solid var(--lm-line); text-align: right; }
|
|
42
|
+
.lm-time-clock { font-family: var(--lm-mono); color: var(--lm-black); font-variant-numeric: tabular-nums; }
|
|
43
|
+
.lm-row-main { flex: 1; min-width: 0; }
|
|
44
|
+
.lm-row-subject { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: var(--lm-black); text-decoration: none; }
|
|
45
|
+
.lm-row-subject::after { content: ""; position: absolute; inset: 0; }
|
|
46
|
+
.lm-row-subject:hover { text-decoration: underline; }
|
|
47
|
+
.lm-row-to { margin: .25rem 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--lm-mono); font-size: .75rem; color: var(--lm-muted); }
|
|
48
|
+
.lm-row-delete { position: relative; flex-shrink: 0; }
|
|
49
|
+
.lm-row-delete form { margin: 0; }
|
|
50
|
+
.lm-row-delete .lm-button { border-color: transparent; background: transparent; color: var(--lm-muted); padding: .375rem .75rem; }
|
|
51
|
+
.lm-row-delete .lm-button:hover { border-color: var(--lm-line); background: var(--lm-white); color: var(--lm-danger); }
|
|
52
|
+
.lm-meta { padding: 1.5rem; }
|
|
53
|
+
.lm-meta h1 { margin: 0; font-size: 1.125rem; color: var(--lm-black); }
|
|
54
|
+
.lm-headers { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .375rem 1rem; margin: 1rem 0 0; font-family: var(--lm-mono); font-size: .75rem; }
|
|
55
|
+
.lm-headers dt { text-transform: uppercase; letter-spacing: .05em; color: var(--lm-muted); }
|
|
56
|
+
.lm-headers dd { margin: 0; word-break: break-all; }
|
|
57
|
+
.lm-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
58
|
+
.lm-tabs { display: flex; border-top: 1px solid var(--lm-line); padding: 0 1.5rem; }
|
|
59
|
+
.lm-tabs label { margin-bottom: -1px; padding: .75rem 1rem; border-bottom: 2px solid transparent; font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: var(--lm-muted); cursor: pointer; }
|
|
60
|
+
.lm-tabs label:first-child { padding-left: 0; }
|
|
61
|
+
.lm-tabs label:hover { color: var(--lm-black); }
|
|
62
|
+
#view-0:checked ~ .lm-tabs .lm-tab-0,
|
|
63
|
+
#view-1:checked ~ .lm-tabs .lm-tab-1,
|
|
64
|
+
#view-2:checked ~ .lm-tabs .lm-tab-2 { color: var(--lm-black); border-bottom-color: var(--lm-black); }
|
|
65
|
+
#view-0:focus-visible ~ .lm-tabs .lm-tab-0,
|
|
66
|
+
#view-1:focus-visible ~ .lm-tabs .lm-tab-1,
|
|
67
|
+
#view-2:focus-visible ~ .lm-tabs .lm-tab-2 { outline: 2px solid var(--lm-black); outline-offset: -2px; }
|
|
68
|
+
.lm-viewport { display: none; align-items: center; justify-content: center; gap: .5rem; padding: .75rem 1.5rem; border-top: 1px solid var(--lm-line); }
|
|
69
|
+
#view-0:checked ~ .lm-viewport { display: flex; }
|
|
70
|
+
.lm-viewport label { display: flex; align-items: center; gap: .5rem; padding: .5rem 1rem; border: 1px solid var(--lm-line); border-radius: .5rem; background: var(--lm-white); color: var(--lm-muted); font-weight: 500; cursor: pointer; }
|
|
71
|
+
.lm-viewport label:hover { background: var(--lm-wash); color: var(--lm-black); }
|
|
72
|
+
.lm-viewport svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
|
73
|
+
#vp-desktop:checked ~ .lm-viewport .lm-vp-desktop,
|
|
74
|
+
#vp-mobile:checked ~ .lm-viewport .lm-vp-mobile { border-color: var(--lm-black); color: var(--lm-black); }
|
|
75
|
+
#vp-desktop:focus-visible ~ .lm-viewport .lm-vp-desktop,
|
|
76
|
+
#vp-mobile:focus-visible ~ .lm-viewport .lm-vp-mobile { outline: 2px solid var(--lm-black); outline-offset: -2px; }
|
|
77
|
+
.lm-panels { padding: 1rem; border-top: 1px solid var(--lm-line); background: var(--lm-wash); }
|
|
78
|
+
.lm-panel { display: none; }
|
|
79
|
+
#view-0:checked ~ .lm-panels .lm-p0,
|
|
80
|
+
#view-1:checked ~ .lm-panels .lm-p1,
|
|
81
|
+
#view-2:checked ~ .lm-panels .lm-p2 { display: block; }
|
|
82
|
+
.lm-frame { display: block; width: 100%; max-width: 100%; height: 72vh; min-height: 420px; margin: 0 auto; border: 0; border-radius: .5rem; background: var(--lm-white); box-shadow: 0 1px 2px rgb(0 0 0 / .05); transition: max-width 150ms ease; }
|
|
83
|
+
#vp-mobile:checked ~ .lm-panels .lm-frame { max-width: 390px; }
|
|
84
|
+
.lm-source { max-height: 72vh; margin: 0; overflow: auto; padding: 1rem; white-space: pre-wrap; word-break: break-word; border: 1px solid var(--lm-line); border-radius: .5rem; background: var(--lm-white); font-family: var(--lm-mono); font-size: .75rem; line-height: 1.6; }
|
|
85
|
+
@media (min-width: 640px) { .lm-panels { padding: 1.5rem; } }
|
|
86
|
+
@media (max-width: 639px) { .lm-time { display: none; } .lm-row { padding: 1rem; } }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<% content_for :title, @messages.any? ? "Inbox (#{@messages.size})" : "Inbox" %>
|
|
2
|
+
|
|
3
|
+
<div class="lm-toolbar">
|
|
4
|
+
<div class="lm-container lm-toolbar-inner">
|
|
5
|
+
<span class="lm-label">Held mail</span>
|
|
6
|
+
<span class="lm-strong"><%= pluralize(@messages.size, "message") %></span>
|
|
7
|
+
<span class="lm-small lm-muted">Kept <%= Localmail.config.ttl.inspect %> · newest <%= Localmail.config.max_messages %></span>
|
|
8
|
+
|
|
9
|
+
<div class="lm-toolbar-actions">
|
|
10
|
+
<%= link_to "Refresh", root_path, class: "lm-button" %>
|
|
11
|
+
<% if @messages.any? %>
|
|
12
|
+
<%= button_to "Clear all", messages_path, method: :delete, class: "lm-button lm-button-danger" %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="lm-container lm-page">
|
|
19
|
+
<% if @messages.empty? %>
|
|
20
|
+
<div class="lm-card lm-empty">
|
|
21
|
+
<h1>Nothing held yet</h1>
|
|
22
|
+
<p>Every email captured while Localmail is on lands here instead of going out. Trigger one and refresh.</p>
|
|
23
|
+
</div>
|
|
24
|
+
<% else %>
|
|
25
|
+
<div class="lm-card">
|
|
26
|
+
<ul class="lm-list">
|
|
27
|
+
<% @messages.each do |message| %>
|
|
28
|
+
<li class="lm-row">
|
|
29
|
+
<time class="lm-time" datetime="<%= message.date&.iso8601 %>">
|
|
30
|
+
<span class="lm-time-clock"><%= message.date&.strftime("%H:%M:%S") || "--:--:--" %></span>
|
|
31
|
+
<span class="lm-label"><%= message.date&.strftime("%d %b") %></span>
|
|
32
|
+
</time>
|
|
33
|
+
|
|
34
|
+
<div class="lm-row-main">
|
|
35
|
+
<%= link_to message.subject.presence || "(no subject)", message_path(message.id), class: "lm-row-subject" %>
|
|
36
|
+
<p class="lm-row-to">to <%= Array(message.to).join(", ") %></p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="lm-row-delete">
|
|
40
|
+
<%= button_to "Delete", message_path(message.id), method: :delete, class: "lm-button" %>
|
|
41
|
+
</div>
|
|
42
|
+
</li>
|
|
43
|
+
<% end %>
|
|
44
|
+
</ul>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<% content_for :title, @message.subject.presence || "(no subject)" %>
|
|
2
|
+
<% views = [] %>
|
|
3
|
+
<% views << [ :html, "HTML" ] if @message.preview_html.present? %>
|
|
4
|
+
<% views << [ :text, "Plain text" ] if @message.text_body.present? %>
|
|
5
|
+
<% views << [ :raw, "Source" ] %>
|
|
6
|
+
|
|
7
|
+
<div class="lm-toolbar">
|
|
8
|
+
<div class="lm-container lm-toolbar-inner">
|
|
9
|
+
<%= link_to "← All mail", root_path, class: "lm-link" %>
|
|
10
|
+
<div class="lm-toolbar-actions">
|
|
11
|
+
<%= button_to "Delete", message_path(@message.id), method: :delete, class: "lm-button lm-button-danger" %>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div class="lm-container lm-page">
|
|
17
|
+
<div class="lm-card">
|
|
18
|
+
<% views.each_index do |i| %>
|
|
19
|
+
<input class="lm-sr-only" type="radio" name="view" id="view-<%= i %>" <%= "checked" if i.zero? %>>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<% if @message.preview_html.present? %>
|
|
23
|
+
<input class="lm-sr-only" type="radio" name="viewport" id="vp-desktop" aria-label="Desktop preview width" checked>
|
|
24
|
+
<input class="lm-sr-only" type="radio" name="viewport" id="vp-mobile" aria-label="Mobile preview width">
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<div class="lm-meta">
|
|
28
|
+
<h1><%= @message.subject.presence || "(no subject)" %></h1>
|
|
29
|
+
|
|
30
|
+
<dl class="lm-headers">
|
|
31
|
+
<dt>From</dt>
|
|
32
|
+
<dd><%= Array(@message.from).join(", ") %></dd>
|
|
33
|
+
<dt>To</dt>
|
|
34
|
+
<dd><%= Array(@message.to).join(", ") %></dd>
|
|
35
|
+
<% if @message.cc.present? %>
|
|
36
|
+
<dt>Cc</dt>
|
|
37
|
+
<dd><%= Array(@message.cc).join(", ") %></dd>
|
|
38
|
+
<% end %>
|
|
39
|
+
<dt>Sent</dt>
|
|
40
|
+
<dd><%= @message.date&.strftime("%d %b %Y, %H:%M:%S") || "Unknown" %></dd>
|
|
41
|
+
</dl>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="lm-tabs">
|
|
45
|
+
<% views.each_with_index do |(_view, label), i| %>
|
|
46
|
+
<label for="view-<%= i %>" class="lm-tab-<%= i %>"><%= label %></label>
|
|
47
|
+
<% end %>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<% if @message.preview_html.present? %>
|
|
51
|
+
<div class="lm-viewport">
|
|
52
|
+
<label for="vp-desktop" class="lm-vp-desktop">
|
|
53
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
54
|
+
<rect x="2" y="3" width="20" height="14" rx="2"></rect>
|
|
55
|
+
<path d="M8 21h8"></path>
|
|
56
|
+
<path d="M12 17v4"></path>
|
|
57
|
+
</svg>
|
|
58
|
+
Desktop
|
|
59
|
+
</label>
|
|
60
|
+
<label for="vp-mobile" class="lm-vp-mobile">
|
|
61
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
62
|
+
<rect x="5" y="2" width="14" height="20" rx="2"></rect>
|
|
63
|
+
<path d="M12 18h.01"></path>
|
|
64
|
+
</svg>
|
|
65
|
+
Mobile
|
|
66
|
+
</label>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<div class="lm-panels">
|
|
71
|
+
<% views.each_with_index do |(view, _label), i| %>
|
|
72
|
+
<section class="lm-panel lm-p<%= i %>">
|
|
73
|
+
<% case view %>
|
|
74
|
+
<% when :html %>
|
|
75
|
+
<iframe sandbox="allow-popups allow-popups-to-escape-sandbox"
|
|
76
|
+
srcdoc="<%= @message.preview_html %>"
|
|
77
|
+
title="Email preview"
|
|
78
|
+
class="lm-frame"></iframe>
|
|
79
|
+
<% when :text %>
|
|
80
|
+
<pre class="lm-source"><%= @message.text_body %></pre>
|
|
81
|
+
<% else %>
|
|
82
|
+
<pre class="lm-source"><%= @message.raw %></pre>
|
|
83
|
+
<% end %>
|
|
84
|
+
</section>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class CreateLocalmailMessages < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :localmail_messages do |t|
|
|
4
|
+
t.string :key, null: false, index: { unique: true }
|
|
5
|
+
t.binary :raw, null: false, limit: 16.megabytes - 1
|
|
6
|
+
t.datetime :created_at, null: false, index: true
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
# The opt-in macro, included into every mailer. A mailer names the actions it wants
|
|
3
|
+
# captured:
|
|
4
|
+
#
|
|
5
|
+
# capture_in_localmail :welcome # just that action
|
|
6
|
+
# capture_in_localmail # every action on the mailer
|
|
7
|
+
#
|
|
8
|
+
# Anything undeclared keeps the app's configured delivery method. The swap happens per
|
|
9
|
+
# message, at delivery time, so a mailer delivered from a background job is decided
|
|
10
|
+
# when it sends rather than when the class loaded.
|
|
11
|
+
module Capture
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
class_attribute :localmail_captured_actions, instance_accessor: false, default: nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class_methods do
|
|
19
|
+
def capture_in_localmail(*actions)
|
|
20
|
+
self.localmail_captured_actions = actions.map(&:to_sym)
|
|
21
|
+
after_action :localmail_capture, only: actions.presence, if: -> { Localmail.capturing? }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def localmail_capture
|
|
28
|
+
message.delivery_method(Localmail::DeliveryMethod)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
# Settings for capture, storage and the inbox. Set them with Localmail.configure.
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :ttl, :max_messages, :capture_in_test, :parent_controller, :authenticate
|
|
5
|
+
attr_accessor :store, :redis
|
|
6
|
+
attr_writer :enabled, :namespace
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@enabled = nil
|
|
10
|
+
@store = :active_record
|
|
11
|
+
@redis = nil
|
|
12
|
+
@namespace = nil
|
|
13
|
+
@ttl = 3.days
|
|
14
|
+
@max_messages = 50
|
|
15
|
+
@capture_in_test = false
|
|
16
|
+
@parent_controller = "ActionController::Base"
|
|
17
|
+
@authenticate = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enabled?
|
|
21
|
+
return ActiveModel::Type::Boolean.new.cast(ENV.fetch("CAPTURE_EMAILS", nil)) == true if @enabled.nil?
|
|
22
|
+
|
|
23
|
+
@enabled.respond_to?(:call) ? @enabled.call == true : @enabled == true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The :redis store's key prefix. Scoped to the app and environment so neither another
|
|
27
|
+
# app on the same local Redis nor a test run can read or wipe the inbox.
|
|
28
|
+
def namespace
|
|
29
|
+
@namespace || "localmail:#{Rails.application.class.module_parent_name.underscore}:#{Rails.env}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
# ActionMailer delivery method that stores mail instead of sending it.
|
|
3
|
+
class DeliveryMethod
|
|
4
|
+
attr_accessor :settings
|
|
5
|
+
|
|
6
|
+
def initialize(settings = {})
|
|
7
|
+
@settings = settings
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Logged before re-raising because with raise_delivery_errors off, ActionMailer
|
|
11
|
+
# swallows the error and the message is neither captured nor sent.
|
|
12
|
+
def deliver!(mail)
|
|
13
|
+
Store.save(mail)
|
|
14
|
+
rescue StandardError => error
|
|
15
|
+
Rails.logger.error("Localmail: failed to capture #{mail.subject.inspect}: #{error.class}: #{error.message}")
|
|
16
|
+
raise
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
# Mounts the inbox and wires capture into ActionMailer.
|
|
3
|
+
class Engine < ::Rails::Engine
|
|
4
|
+
isolate_namespace Localmail
|
|
5
|
+
|
|
6
|
+
initializer "localmail.action_mailer" do
|
|
7
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
8
|
+
add_delivery_method :localmail, Localmail::DeliveryMethod
|
|
9
|
+
include Localmail::Capture
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
# One captured email, rebuilt from its stored raw source, with the parts the inbox
|
|
3
|
+
# renders exposed as UTF-8 strings.
|
|
4
|
+
class Message
|
|
5
|
+
attr_reader :id, :mail
|
|
6
|
+
|
|
7
|
+
delegate :subject, :from, :to, :cc, :bcc, :date, to: :mail
|
|
8
|
+
|
|
9
|
+
def initialize(id, raw)
|
|
10
|
+
@id = id
|
|
11
|
+
@mail = Mail.read_from_string(raw)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def html_body
|
|
15
|
+
body_for("text/html")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def preview_html
|
|
19
|
+
return if html_body.nil?
|
|
20
|
+
|
|
21
|
+
%(<base target="_blank">#{html_body})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def text_body
|
|
25
|
+
body_for("text/plain")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def raw
|
|
29
|
+
as_utf8(mail.to_s, mail.charset)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def body_for(mime_type)
|
|
35
|
+
part = part_for(mime_type)
|
|
36
|
+
return if part.nil?
|
|
37
|
+
|
|
38
|
+
as_utf8(part.body.decoded, part.charset)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def part_for(mime_type)
|
|
42
|
+
return mail if !mail.multipart? && mail.mime_type == mime_type
|
|
43
|
+
|
|
44
|
+
mail.all_parts.find { |part| part.mime_type == mime_type }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Quoted-printable and base64 bodies decode to ASCII-8BIT. Re-tag with the part's own
|
|
48
|
+
# charset before it reaches a UTF-8 view, or ERB raises on concatenation.
|
|
49
|
+
def as_utf8(string, charset)
|
|
50
|
+
string.dup
|
|
51
|
+
.force_encoding(charset.presence || Encoding::UTF_8)
|
|
52
|
+
.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
53
|
+
rescue ArgumentError
|
|
54
|
+
string.dup.force_encoding(Encoding::UTF_8).scrub
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Localmail
|
|
2
|
+
module Stores
|
|
3
|
+
# Keeps captured mail in the host's database, which every web and worker process
|
|
4
|
+
# already shares. Needs the localmail_messages table (bin/rails localmail:install:migrations).
|
|
5
|
+
class ActiveRecord
|
|
6
|
+
# One captured message's raw source.
|
|
7
|
+
class Record < ::ActiveRecord::Base
|
|
8
|
+
self.table_name = "localmail_messages"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def save(mail)
|
|
12
|
+
SecureRandom.uuid.tap do |id|
|
|
13
|
+
Record.transaction do
|
|
14
|
+
Record.create!(key: id, raw: mail.to_s)
|
|
15
|
+
prune
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all
|
|
21
|
+
live.order(created_at: :desc, id: :desc).limit(max_messages).map { |record| to_message(record) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find(id)
|
|
25
|
+
record = live.find_by(key: id)
|
|
26
|
+
return if record.nil?
|
|
27
|
+
|
|
28
|
+
to_message(record)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete(id)
|
|
32
|
+
Record.where(key: id).delete_all
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear
|
|
36
|
+
Record.delete_all
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def prune
|
|
42
|
+
Record.where(created_at: ...cutoff).delete_all
|
|
43
|
+
overflow = Record.order(created_at: :desc, id: :desc).offset(max_messages).pluck(:id)
|
|
44
|
+
Record.where(id: overflow).delete_all if overflow.any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def live
|
|
48
|
+
Record.where(created_at: cutoff..)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_message(record)
|
|
52
|
+
Message.new(record.key, record.raw)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cutoff
|
|
56
|
+
Localmail.config.ttl.ago
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def max_messages
|
|
60
|
+
Localmail.config.max_messages
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "redis"
|
|
3
|
+
require "connection_pool"
|
|
4
|
+
rescue LoadError
|
|
5
|
+
raise LoadError, "Localmail's :redis store needs the redis and connection_pool gems. Add them to your Gemfile."
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module Localmail
|
|
9
|
+
module Stores
|
|
10
|
+
# Keeps captured mail in Redis: a list of ids newest-first plus one key per message,
|
|
11
|
+
# both bounded by the configured TTL and cap.
|
|
12
|
+
class Redis
|
|
13
|
+
def save(mail)
|
|
14
|
+
SecureRandom.uuid.tap { |id| write(id, mail.to_s, evicted_by_next_save) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def all
|
|
18
|
+
ids = redis.lrange(list_key, 0, -1)
|
|
19
|
+
return [] if ids.empty?
|
|
20
|
+
|
|
21
|
+
raws = redis.mget(message_keys(ids))
|
|
22
|
+
ids.zip(raws).filter_map { |id, raw| Message.new(id, raw) if raw }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find(id)
|
|
26
|
+
raw = redis.get(message_key(id))
|
|
27
|
+
return if raw.nil?
|
|
28
|
+
|
|
29
|
+
Message.new(id, raw)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete(id)
|
|
33
|
+
redis.multi do |transaction|
|
|
34
|
+
transaction.del(message_key(id))
|
|
35
|
+
transaction.lrem(list_key, 1, id)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear
|
|
40
|
+
ids = redis.lrange(list_key, 0, -1)
|
|
41
|
+
redis.del(list_key, *message_keys(ids))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def redis
|
|
45
|
+
@redis ||= build_redis
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def write(id, raw, evicted)
|
|
51
|
+
redis.multi do |transaction|
|
|
52
|
+
transaction.setex(message_key(id), ttl, raw)
|
|
53
|
+
transaction.lpush(list_key, id)
|
|
54
|
+
transaction.ltrim(list_key, 0, max_messages - 1)
|
|
55
|
+
transaction.expire(list_key, ttl)
|
|
56
|
+
transaction.del(*message_keys(evicted)) if evicted.any?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# LTRIM drops ids off the tail but leaves their message keys behind, invisible to
|
|
61
|
+
# the inbox until their own TTL runs out. These are the ids the next save pushes out.
|
|
62
|
+
def evicted_by_next_save
|
|
63
|
+
redis.lrange(list_key, max_messages - 1, -1)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_redis
|
|
67
|
+
client = Localmail.config.redis
|
|
68
|
+
case client
|
|
69
|
+
when nil then ConnectionPool::Wrapper.new { ::Redis.new }
|
|
70
|
+
when Proc then ConnectionPool::Wrapper.new { client.call }
|
|
71
|
+
else client
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def message_keys(ids)
|
|
76
|
+
ids.map { |id| message_key(id) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def message_key(id)
|
|
80
|
+
"#{Localmail.config.namespace}:message:#{id}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def list_key
|
|
84
|
+
"#{Localmail.config.namespace}:messages"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ttl
|
|
88
|
+
Localmail.config.ttl.to_i
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def max_messages
|
|
92
|
+
Localmail.config.max_messages
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/localmail.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "mail"
|
|
2
|
+
require "active_support"
|
|
3
|
+
require "active_support/core_ext/integer/time"
|
|
4
|
+
|
|
5
|
+
require "localmail/version"
|
|
6
|
+
require "localmail/configuration"
|
|
7
|
+
require "localmail/message"
|
|
8
|
+
require "localmail/store"
|
|
9
|
+
require "localmail/delivery_method"
|
|
10
|
+
require "localmail/capture"
|
|
11
|
+
require "localmail/engine"
|
|
12
|
+
|
|
13
|
+
# Captures outgoing mail and serves it from a mountable inbox.
|
|
14
|
+
module Localmail
|
|
15
|
+
STORES = {
|
|
16
|
+
active_record: [ "localmail/stores/active_record", "Localmail::Stores::ActiveRecord" ],
|
|
17
|
+
redis: [ "localmail/stores/redis", "Localmail::Stores::Redis" ]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def config
|
|
22
|
+
@config ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure
|
|
26
|
+
yield config
|
|
27
|
+
@store = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset_config!
|
|
31
|
+
@config = nil
|
|
32
|
+
@store = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Whether capture is switched on at all. Also decides whether the inbox serves.
|
|
36
|
+
def enabled?
|
|
37
|
+
config.enabled?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether a message being delivered right now should be captured.
|
|
41
|
+
def capturing?
|
|
42
|
+
enabled? && (config.capture_in_test || !Rails.env.test?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def store
|
|
46
|
+
@store ||= build_store
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_store
|
|
52
|
+
choice = config.store
|
|
53
|
+
return choice unless choice.is_a?(Symbol)
|
|
54
|
+
|
|
55
|
+
path, class_name = STORES.fetch(choice) do
|
|
56
|
+
raise ArgumentError, "Unknown Localmail store #{choice.inspect}. Use one of #{STORES.keys.inspect} or a store object."
|
|
57
|
+
end
|
|
58
|
+
require path
|
|
59
|
+
class_name.constantize.new
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: localmail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- John Arnold
|
|
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: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
description: 'Localmail swaps ActionMailer''s delivery for the mailer actions you
|
|
27
|
+
name, stores the message in your database or Redis, and serves it from a mountable
|
|
28
|
+
inbox. It works where mailcatcher and letter_opener_web cannot: across dynos and
|
|
29
|
+
containers that share no disk.'
|
|
30
|
+
email:
|
|
31
|
+
- 41114838+jaarnie@users.noreply.github.com
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- MIT-LICENSE
|
|
38
|
+
- README.md
|
|
39
|
+
- Rakefile
|
|
40
|
+
- app/controllers/localmail/application_controller.rb
|
|
41
|
+
- app/controllers/localmail/messages_controller.rb
|
|
42
|
+
- app/views/layouts/localmail/application.html.erb
|
|
43
|
+
- app/views/localmail/_styles.css.erb
|
|
44
|
+
- app/views/localmail/messages/index.html.erb
|
|
45
|
+
- app/views/localmail/messages/show.html.erb
|
|
46
|
+
- config/routes.rb
|
|
47
|
+
- db/migrate/20261001000000_create_localmail_messages.rb
|
|
48
|
+
- lib/localmail.rb
|
|
49
|
+
- lib/localmail/capture.rb
|
|
50
|
+
- lib/localmail/configuration.rb
|
|
51
|
+
- lib/localmail/delivery_method.rb
|
|
52
|
+
- lib/localmail/engine.rb
|
|
53
|
+
- lib/localmail/message.rb
|
|
54
|
+
- lib/localmail/store.rb
|
|
55
|
+
- lib/localmail/stores/active_record.rb
|
|
56
|
+
- lib/localmail/stores/redis.rb
|
|
57
|
+
- lib/localmail/version.rb
|
|
58
|
+
homepage: https://github.com/jaarnie/localmail
|
|
59
|
+
licenses: []
|
|
60
|
+
metadata:
|
|
61
|
+
homepage_uri: https://github.com/jaarnie/localmail
|
|
62
|
+
source_code_uri: https://github.com/jaarnie/localmail
|
|
63
|
+
changelog_uri: https://github.com/jaarnie/localmail/blob/main/CHANGELOG.md
|
|
64
|
+
rubygems_mfa_required: 'true'
|
|
65
|
+
rdoc_options: []
|
|
66
|
+
require_paths:
|
|
67
|
+
- lib
|
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '3.2'
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '0'
|
|
78
|
+
requirements: []
|
|
79
|
+
rubygems_version: 3.6.9
|
|
80
|
+
specification_version: 4
|
|
81
|
+
summary: An inbox for email captured from deployed Rails environments.
|
|
82
|
+
test_files: []
|