Project

postpeek

0.0
The project is in a healthy, maintained state
Postpeek intercepts ActionMailer deliveries in development, decodes content, enriches metadata (mailer class, action, duration), and stores each email as a structured directory on disk. A companion UI gem (postpeek_ui) reads the stored emails and provides a filterable web interface.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 2.8
>= 6.1
 Project Readme

Postpeek

Postpeek is an ActionMailer delivery method for Rails development that intercepts outgoing emails, decodes their content, and stores them as structured directories on disk. Each email gets its own folder containing the raw source, decoded HTML and plain-text bodies, attachments, and a rich metadata JSON file — ready to be inspected by the companion postpeek-ui web interface or any other tool.


Installation

Add to your Gemfile:

gem "postpeek", group: :development

Then run:

bundle install

Setup

1. Configure the delivery method

In config/environments/development.rb:

config.action_mailer.delivery_method = :postpeek

2. Create an initializer

config/initializers/postpeek.rb:

Postpeek.configure do |c|
  # Where to store emails. Defaults to Rails.root/tmp/postpeek.
  c.storage_path = Rails.root.join("tmp", "postpeek")

  # Maximum number of emails to keep on disk. Oldest are pruned automatically.
  # Set to nil to keep everything.
  c.max_emails = 100

  # Auto-prune old emails when max_emails is exceeded.
  c.auto_prune = true

  # How to handle emails with an unrecognised Content-Transfer-Encoding:
  #   :raise  — raise Postpeek::Decoders::UnknownEncodingError (default)
  #   :warn   — log a warning and skip the part
  #   :skip   — silently skip the part
  c.unknown_encoding = :raise
end

Stored Layout

Each intercepted email is saved under <storage_path>/<id>/:

tmp/postpeek/
└── 20240615T120000_a1b2c3d4/
    ├── metadata.json     # structured metadata
    ├── message.eml       # raw RFC 2822 source
    ├── rich.html         # decoded HTML part (if present)
    ├── plain.txt         # decoded plain-text part (if present)
    └── attachments/
        └── report.pdf    # decoded attachment binaries

metadata.json schema

{
  "id": "20240615T120000_a1b2c3d4",
  "subject": "Welcome to Acme!",
  "from": ["app@acme.com"],
  "to": ["user@example.com"],
  "cc": [],
  "bcc": [],
  "reply_to": null,
  "sent_at": "2024-06-15T12:00:00Z",
  "content_type": "multipart/alternative",
  "parts": [
    { "content_type": "text/html", "encoding": "quoted-printable", "charset": "UTF-8" },
    { "content_type": "text/plain", "encoding": "7bit", "charset": "UTF-8" }
  ],
  "encoding": null,
  "locale": "en",
  "attachments": [],
  "message_id": "abc123@acme.com",
  "mailer_class": "UserMailer",
  "mailer_action": "welcome",
  "delivery_duration_ms": 3,
  "tags": []
}

After-Save Hooks

Run custom code after each email is stored:

Postpeek.configure do |c|
  c.after_save_hook do |message|
    Rails.logger.info "Postpeek saved: #{message.id}#{message.subject}"
  end
end

The message object exposes:

Method Returns
message.id String — storage ID
message.subject String | nil
message.from Array<String>
message.to Array<String>
message.mailer_class String | nil
message.mailer_action String | nil
message.html_body String | nil
message.text_body String | nil
message.attachments Array<Hash>
message.metadata Hash — full metadata
message.raw_source String — RFC 2822 source

Errors raised inside a hook are rescued and printed to $stderr so one failing hook never blocks others.


Custom Metadata Builders

Extend the metadata hash with your own fields:

Postpeek.configure do |c|
  c.metadata_builder do |metadata|
    metadata.merge(
      app_version: ENV["APP_VERSION"],
      request_id:  Thread.current[:current_request_id]
    )
  end
end

Each callable receives the current metadata hash and must return a Hash. The returned hash is merged into the final metadata. Returning a non-Hash raises ArgumentError.


Known Limitations (v0.1)

  • deliver_later is not supported. Active Job runs the delivery in a worker thread where the ActionMailer context (mailer_class, mailer_action) is no longer present. Both fields will be nil in metadata. Use deliver_now in development.
  • The storage backend is filesystem-only in v0.1. Custom backends can be passed as a class: c.storage_backend = MyBackend.

Development

bin/setup          # install dependencies
bundle exec rspec  # run the test suite
bundle exec rubocop

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/mmarusyk/postpeek. This project follows the Contributor Covenant code of conduct.


License

The gem is available as open source under the MIT License.