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 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,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -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,5 @@
1
+ Localmail::Engine.routes.draw do
2
+ root "messages#index"
3
+ delete "/", to: "messages#destroy_all", as: :messages
4
+ resources :messages, path: "", only: %i[show destroy]
5
+ end
@@ -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,12 @@
1
+ module Localmail
2
+ # Where captured mail lives. Delegates to the store chosen by config.store.
3
+ module Store
4
+ class << self
5
+ delegate :save, :all, :find, :delete, :clear, to: :backend
6
+
7
+ def backend
8
+ Localmail.store
9
+ end
10
+ end
11
+ end
12
+ 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
@@ -0,0 +1,3 @@
1
+ module Localmail
2
+ VERSION = "0.2.0"
3
+ 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: []