Mailscope
A mail inspector for Rails development and staging environments. It captures
everything your app sends and gives you a fast web UI to search, preview and
inspect it: HTML, plain text, the raw .eml, real headers and attachments —
with one mailbox per recipient, responsive previews and remote-content
blocking.
Mailscope started as a fork of
letter_opener_web and was
rebuilt on a different foundation: instead of regex-matching the HTML that
letter_opener already rendered, it stores the original .eml plus a small
metadata index, and reads everything from there.
Installation
Requires Ruby 3.1+ and Rails 7.1+.
group :development do
gem 'mailscope'
end
config/routes.rb:
Rails.application.routes.draw do
mount Mailscope::Engine, at: '/mailscope' if Rails.env.development?
end
config/environments/development.rb:
config.action_mailer.delivery_method = :mailscope
That's it. Send an email and open http://localhost:3000/mailscope.
What it does
| | |
|---|---|
| Mailboxes per recipient | One entry per address that received mail (To, Cc and Bcc), with counts. |
| Search | Subject, sender, recipient, mailer class and the plain-text body. |
| HTML / Text / Source / Headers / Attachments tabs | The raw .eml and the real headers, not an approximation. |
| Responsive preview | Switch the preview width between desktop, tablet (768px) and mobile (390px). |
| Remote-content blocking | The preview runs in a sandboxed iframe under a strict CSP; external images are blocked by default and the app tells you how many. Inline (cid:) images become data: URIs. |
| Live updates | Opt-in: flip the switch in the header and the list refreshes itself when new mail arrives. The choice sticks per browser. |
| Light / dark theme | A real theme with a one-click toggle — not a filter: invert() hack. The HTML preview deliberately stays on white: it is the message as its recipients will see it. The plain-text view, which has no sender design to preserve, follows the theme. |
| Keyboard | j/k to move, / to search, x to delete, 1–5 to switch tabs, ? for the full list. |
| .eml download | Reopen a message in any client, or attach it to a bug report. |
| JSON API | GET /mailscope/?format=json — handy in system tests. Accepts q, mailbox and only=attachments\|html\|text. |
| Retention | Old messages are discarded automatically; tmp/ does not grow forever. |
| i18n | English and Brazilian Portuguese, following the app's I18n.locale. |
Configuration
Mailscope.configure do |config|
# Where messages live. Default: Rails.root.join('tmp', 'mailscope')
config.location = Rails.root.join('tmp', 'mailscope')
# :filesystem (default) or any object implementing Mailscope::Storage::Base
config.storage = :filesystem
# Retention, applied after every delivery. nil disables the check.
config.max_letters = 500
config.max_age = 7.days
# Access guard. Receives the ActionDispatch::Request; return falsy to deny.
config.authenticate_with = ->(request) { request.local? }
# Initial state of the "remote content" switch in the preview pane.
config.block_remote_content = true
# Browser polling for newly delivered mail. This is only the default state of
# the header switch — the user's own choice is remembered per browser.
config.auto_refresh = false
config.auto_refresh_interval = 3 # seconds
# :system (default), :light or :dark — the user can override it in the UI.
config.default_theme = :system
config.title = 'Mailscope'
end
Authentication
People routinely mount tools like this on staging. Rather than relying on you
remembering to wrap the route in a constraint, authenticate_with is part of
the configuration:
Mailscope.configure do |config|
config.authenticate_with = lambda do |request|
ActiveSupport::SecurityUtils.secure_compare(
request.headers['X-Mailscope-Token'].to_s, ENV.fetch('MAILSCOPE_TOKEN')
)
end
end
The block is evaluated in the controller's context, so session, cookies and
your application's helpers are available.
Staging / pre-production use
- Move the gem out of the
:developmentgroup in yourGemfile. - Set
config.action_mailer.delivery_method = :mailscopefor that environment. - Mount the route there and configure
authenticate_with.
The :filesystem adapter keeps messages on the process's own disk. With more
than one instance (several dynos, a separate worker) each instance only sees
what it delivered itself — write a shared storage adapter
(Mailscope::Storage::Base has seven methods) and point config.storage at it.
Preview security
The message body is served from its own route and rendered inside an iframe
with sandbox (no allow-scripts, no allow-same-origin) under a strict
response Content-Security-Policy:
default-src 'none'; script-src 'none'; frame-src 'none'; object-src 'none';
form-action 'none'; base-uri 'none'; style-src 'unsafe-inline' data:;
font-src data:; img-src data:
With the remote-content switch on, img-src additionally accepts https: and
http:. Scripts never run.
Migrating from letter_opener_web
-gem 'letter_opener_web', '~> 3.0'
+gem 'mailscope'
-mount LetterOpenerWeb::Engine, at: '/letter_opener'
+mount Mailscope::Engine, at: '/mailscope'
The :letter_opener_web delivery method stays registered as an alias, so
config.action_mailer.delivery_method does not have to change right away.
config.letters_location also still works as an alias of config.location,
and old letter_opener directories (1358825621_ba83a22/rich.html) are listed
read-only.
Worth knowing:
- New messages are stored as
.eml+metadata.json, not as rendered HTML.letter_openeris no longer a dependency. - Routes changed (
/messages/:id,DELETEinstead ofPOST .../delete). - No jQuery, jquery-ujs or Bootstrap inlined into the HTML any more.
Development
bin/setup
bin/rspec
bin/rubocop
# against another Rails version
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle install
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rspec
To run the bundled example app with sample messages:
bundle exec rackup spec/dummy/config.ru -p 3123
Then open http://localhost:3123/seed to generate messages and
http://localhost:3123/mailscope for the UI. MAILSCOPE_LOCALE=en runs it in
English, pt-BR in Portuguese.
Bundled assets
The UI ships Inter (SIL OFL 1.1, see INTER-LICENSE.txt) as two woff2 subsets served from the engine's own digested asset path. Nothing is fetched from a CDN, so the UI looks the same offline and no request leaves the machine.
Releasing
Publishing runs from a tag, authenticated with RubyGems trusted publishing (OIDC) — there is no API key in the repository secrets.
- Bump
Mailscope::VERSIONand move theUnreleasednotes into a dated section in CHANGELOG.md. - Commit, then
git tag vX.Y.Z && git push --tags.
The release workflow reruns the suite, checks the tag against the version,
then builds and pushes the gem via rake release. Write the GitHub release
notes by hand afterwards if you want them.
Credits
Mailscope is a fork of letter_opener_web by Fabio Rehm and David Muto, which in turn grew out of letter_opener by Ryan Bates. Both MIT.
License
MIT. See LICENSE.txt.