MaquinaNewsletters

A mountable Rails 8 engine for drafting, approving, scheduling, and batch-sending HTML newsletters (ActionText + Trix) from a host app's backstage area.

Installation

Add to your Gemfile and bundle:

gem "maquina_newsletters"

Mount the engine (typically under your backstage area):

# config/routes.rb
mount MaquinaNewsletters::Engine => "/backstage/newsletters"

The bundled installer wires up the rest:

bin/rails generate maquina_newsletters:install
bin/rails db:migrate

Requirements

The newsletter body is an Action Text rich-text field with Active Storage image attachments, so the host app must have both set up (the installer runs active_storage:install / action_text:install if they aren't already):

bin/rails active_storage:install
bin/rails action_text:install

Image processing. Images embedded in a newsletter are rendered through blob.representation(...), which resizes a variant. This requires the image_processing gem and a system image library — libvips (recommended) or ImageMagick. Without it the /rails/active_storage/representations/... endpoint returns 500 and images won't display.

# Gemfile
gem "image_processing", "~> 1.2"
# macOS
brew install vips        # or: brew install imagemagick

# Debian/Ubuntu
apt-get install libvips  # or: apt-get install imagemagick

Email image URLs. For images to display in delivered emails the URLs must be absolute, so set a default host for the mailer:

# config/environments/production.rb
config.action_mailer.default_url_options = { host: "newsletters.example.com" }

On the web (the in-app preview) the engine uses the current request's host automatically; in emails it uses this default_url_options host.

Configuration

Configure the engine in an initializer:

# config/initializers/maquina_newsletters.rb
MaquinaNewsletters.configure do |config|
  # Recipient resolution — which records receive a newsletter.
  config.recipient_model      = "User"          # constantized at use-time
  config.recipient_scope      = :active         # a scope returning a relation
  config.recipient_email_attr = :email_address  # the email column

  # Base controller — see "Authentication" below.
  config.base_controller_class = "BackstageController"
end

If the initializer is absent, the defaults shown above are used (except base_controller_class, which defaults to "ActionController::Base").

Authentication

The engine does not authenticate. Authentication is the host's responsibility. This mirrors rails/mission_control-jobs.

The engine's controllers inherit from a configurable base controller:

config.base_controller_class = "BackstageController"
  • The value is a String, constantized at load time.
  • It defaults to "ActionController::Base", so the engine runs standalone.
  • Point it at your app's authenticated base controller (e.g. a BackstageController that already gates the backstage area with HTTP Basic Auth, a session check, etc.). Whatever auth that controller enforces automatically protects the engine, because every engine controller inherits from it.

Optional fallback: built-in HTTP Basic Auth

For hosts whose base controller does not already authenticate, the engine ships an optional HTTP Basic Auth fallback (off by default). Enable it with credentials:

MaquinaNewsletters.configure do |config|
  config.http_basic_auth_enabled  = true
  config.http_basic_auth_user     = ENV["NEWSLETTERS_USER"]
  config.http_basic_auth_password = ENV["NEWSLETTERS_PASSWORD"]
end

When http_basic_auth_enabled is true:

  • with a user and password configured, the engine challenges with HTTP Basic Auth;
  • enabled but without credentials, it fails closed (401 on every request).

Leave it disabled when your base controller already handles auth — don't stack both.

Usage

Visit the mount path (e.g. /backstage/newsletters) to manage newsletters. Each issue moves through a small lifecycle, and scheduling is a single, deliberate step rather than something you set while composing.

Lifecycle

  1. DraftNew / Edit is content only: a subject and the body (ActionText + Trix, with image attachments). Saving creates a draft; no send time is set yet.
  2. Approved — approve a draft when it's ready to go out.
  3. Scheduled — on an approved issue's page you choose when and how it sends, then click Schedule (details below).
  4. Sending → Sent — once scheduled, a background job delivers the issue.

You can step backwards at any point with Back to draft, and Unschedule an issue to return it to approved.

Scheduling (the approve step)

The send time and batching are chosen in one place: the schedule form shown on an approved issue's page. It has three inputs:

  • Date — a date picker restricted to today onward (no past dates).
  • Time — a dropdown from 8:00 AM to 8:00 PM in 30-minute steps.
  • Batch size — recipients per batch; 0 = send to everyone at once. With a positive size the send is split into batches delivered a day apart.

The chosen date + time compose into the issue's scheduled_at. If that moment has already passed (e.g. today, at an earlier time), it rolls forward to the next 30-minute slot and the confirmation message says so. Because scheduled_at is only written here, an issue's read-only summary (Recipients / Scheduled at / Batch size / Sent at) appears once it's scheduled — a draft or approved issue shows no phantom schedule.

Send now and test sends

  • Send now — an overflow (⋮) action that delivers immediately to all recipients, behind a confirmation. Use it sparingly; the normal path is to schedule.
  • Send a test — available while drafting/approving/scheduling: send exactly one email to any address you type, ignoring batch size and schedule. It never changes the issue's state.

Recipients

Recipients are resolved at send time from the configured model + scope (see Configuration), minus the per-issue exclusion list, then downcased, de-duplicated, and sorted for a stable batching order.

Contributing

While iterating, run the specific test for what you're touching, e.g. bin/rails test test/models/maquina_newsletters/newsletter_test.rb. Before considering a change done, run the full pipeline (style + tests):

bin/ci

Style is enforced with Standard — autocorrect with bin/standardrb --fix.

License

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