Mailscope

Gem Version CI License: MIT

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, 15 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

  1. Move the gem out of the :development group in your Gemfile.
  2. Set config.action_mailer.delivery_method = :mailscope for that environment.
  3. 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_opener is no longer a dependency.
  • Routes changed (/messages/:id, DELETE instead of POST .../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.

  1. Bump Mailscope::VERSION and move the Unreleased notes into a dated section in CHANGELOG.md.
  2. 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.