Project

activemail

0.0
The project is in a healthy, maintained state
ActiveMail is a plug & play gem for sending beautiful emails, written entirely in Ruby. It turns simple, semantic tags into responsive, bulletproof email markup and ships with modern standards — dark mode and accessibility — out of the box. Batteries included: design tokens, components, automatic CSS inlining, and generators, all overridable.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 1.16, < 1.20
>= 1.21, < 2
>= 0.5.10600, < 0.7
 Project Readme

ActiveMail

Opinionated, plug & play responsive email for Rails. ActiveMail turns simple, semantic tags into the bulletproof table markup email clients require, and ships a batteries-included layer on top — a themeable SCSS framework, dark mode, design tokens, automatic CSS inlining, generators, and test-time quality guards — so a responsive, accessible email renders out of the box, with every default overridable.

Not affiliated with Rails core.

Write this:

<container>
  <row>
    <columns large="6">Left</columns>
    <columns large="6">Right</columns>
  </row>
</container>

and ActiveMail produces a fluid-hybrid, Outlook-safe table layout with MSO ghost tables, role="presentation" on every table, and inline styles — markup that renders consistently from Apple Mail to Outlook (Word engine) to Gmail mobile.

Features

  • Semantic markup → bulletproof tables (<container>, <row>, <columns>, <button>, <menu>, <callout>, <spacer>, …), with an extensible open/closed component registry.
  • Design tokens as the single Ruby source of truth, bridged to SCSS.
  • Themeable SCSS framework with built-in dark mode (prefers-color-scheme
    • Outlook [data-ogsc]).
  • Automatic CSS inlining via a pluggable adapter (premailer by default, roadie optional, or your own).
  • Generators to install, eject views/styles, and scaffold components.
  • Opt-in quality layer: a Guard (size, role, alt, lang, title, links, placeholders), preview renderer, Minitest assertions + RSpec matcher, and a render_all rake task.

Installation

# Gemfile
gem 'activemail', '~> 2.1'
bundle install
bin/rails g activemail:install

The generator drops a commented initializer, a mailer layout, and wires the framework stylesheet. It works zero-config; customize only what you want.

Name a mailer view welcome.html.inky (or compose with another engine: welcome.html.inky-erb, .inky-slim, .inky-haml) and it is transpiled on render. Each email is transpiled and its CSS inlined exactly once, when the mailer renders it (view and layout together).

Configuration

# config/initializers/activemail.rb
ActiveMail.configure do |config|
  config.template_engine  = :erb   # underlying engine for `.html.inky` (default :erb)
  config.column_count     = 12     # grid columns (default 12)
  config.container_width  = 600    # px width of <container> + MSO ghost table (default 600)
  config.on_parse_error   = :warn  # :ignore, :warn or :raise (default :warn)

  # CSS inliner: :premailer (default), :roadie, :null, or a custom
  # ActiveMail::Inliner::Base subclass/instance.
  config.inliner = :premailer

  # Design tokens (see below).
  config.tokens.color :primary, '#2a9d8f'

  # Custom components (see "Custom components").
  config.register_component 'divider', Divider
end

on_parse_error surfaces HTML the parser had to repair (unclosed/mismatched tags, broken attributes) instead of silently sending a different email than intended. :warn logs via Rails.logger when available (else $stderr); use :raise in CI/staging to fail the build on malformed templates. Registered component tags never trigger it. The parser knows HTML4 — HTML5-only tags (<section>, …) are reported as unknown; register them as components or use :ignore.

Design tokens

Tokens are the single source of truth. Declare them once in Ruby; ActiveMail bridges them to SCSS automatically ($am-color-primary, $am-font-body, $am-spacing-md, …) so a component's inline color always matches the stylesheet.

config.tokens.color   :primary,   '#2a9d8f'
config.tokens.color   :secondary, '#264653'
config.tokens.font    :heading,   'Georgia, serif'
config.tokens.spacing :lg,        '32px'

ActiveMail.tokens.color(:primary) # => "#2a9d8f"

Defaults are neutral (a calm teal primary, near-black text, white background, …) and fully overridable. Optional tokens fall back to a base token: primary_text/secondary_text → button_text, primary_border/ secondary_border → none, info_box_background/_border/_text → background/border/text. Ruby components and the stylesheet read the same resolved values. Under Sprockets the SCSS bridge is a preprocessed partial; under Propshaft run rake activemail:tokens:export to materialize a static _activemail_tokens.scss.

Styling

The framework stylesheet lives at activemail/activemail and is themed entirely by tokens — no hard-coded brand colors. It includes a fluid-hybrid grid, component hooks (.button, .cta, .callout, .spacer, …), utilities, and dark mode.

Override at three levels, cheapest first:

  1. Tokens (Ruby) — covers most theming. Any framework variable can also be configured from your own stylesheet with the Sass module system:

    // app/assets/stylesheets/emails/emails.scss
    @use "activemail/activemail" with (
      $am-button-secondary-color: #264653,
      $am-breakpoint: 640px
    );
  2. bin/rails g activemail:styles — eject the SCSS partials into your app to edit them; your copies shadow the gem's.

  3. bin/rails g activemail:views — eject the default layout + partials (app/views/layouts/activemail/*); a same-named file in your app wins by Rails path precedence. Put your logo/header/footer here — those are the app's identity, not the gem's. The <head> lives in _document_head (metas, <title> set to the message subject, Outlook settings, inlined styles) and is shared by the default layout and the generated one.

Dark mode ships on: a <style> block keys off prefers-color-scheme: dark (Apple Mail/iOS) and Outlook's [data-ogsc], with surfaces derived from your tokens.

CSS inlining

Email clients (Gmail mobile, Orange.fr) strip <style>, so critical CSS must be inlined. When a mailer renders an inky HTML template, ActiveMail transpiles the whole body (view + layout) and runs the configured inliner once. Text parts, attachments and non-ActiveMail emails are never touched; your <style> enhancement block (media queries, dark mode) is preserved. With Premailer the parsed framework CSS is cached per stylesheet, so it is parsed once, not once per email.

The layout embeds the compiled stylesheets with activemail_inline_styles (Sprockets or Propshaft); pass your own on top of the framework's:

<%= activemail_inline_styles %>                  <%# framework only %>
<%= activemail_inline_styles "emails/emails" %>  <%# your stylesheet @use-ing the framework %>

A missing or empty asset raises ActiveMail::StylesheetNotFound: an email is never sent unstyled. An inliner failure raises ActiveMail::Inliner::Error (the adapter's exception is kept as #cause).

config.inliner = :premailer   # default, hard dependency
config.inliner = :roadie      # add `gem 'roadie'` yourself
config.inliner = :null        # opt out (e.g. you run premailer-rails)
config.inliner = MyInliner.new # any ActiveMail::Inliner::Base

Components

Every built-in tag and its rendered output. Tables omit border/cellpadding/cellspacing/role below for brevity — they are always present.

<container>

Fluid-hybrid wrapper, capped at container_width, wrapped in an MSO ghost table.

<container>...</container>
<!--[if mso]><table role="presentation" align="center" width="600">...<![endif]-->
<table class="container" align="center" style="width:100%;max-width:600px;margin:0 auto;">
  <tbody><tr><td>...</td></tr></tbody>
</table>
<!--[if mso]></td></tr></table><![endif]-->

<row> / <columns>

Columns use display:inline-block + pixel max-width (natural stacking on mobile) with MSO ghost cells restoring the grid in Outlook. small/large attributes and first/last/small-*/large-* classes are preserved for media-query enhancement.

<row><columns large="6">Hi</columns></row>

Column widths are computed as container_width × large / column_count, capped at container_width. A nested <columns> is sized against its enclosing column (a large="6" inside a large="6" is 150px in a 600px container). Keep the large sizes of a row summing to at most column_count, otherwise the ghost cells will wrap in Outlook.

Gutters are border-box padding inlined on the columns: gutter (8px) on each side between two columns, and container_gutter (16px) on the outer edge of the first and last column, so content never touches the screen edge. A nested row adds no outer gutter. Both are spacing tokens (config.tokens.spacing :container_gutter, '0px' restores edge-to-edge columns) and SCSS variables ($am-gutter, $am-container-gutter).

<button>

Bulletproof button styled inline from the tokens (Tokens#button_style): the padding lives on the <a> so the whole control is clickable (mirrored on the cell with mso-padding-alt for Outlook), the fill and radius sit on the cell only, and a variant border sits on the cell as well. class="secondary" selects the secondary variant; expand makes it full width; without href the label renders as a <span>.

<button href="#" class="secondary">Go</button>
<table class="secondary button"><tbody><tr><td>
  <table><tbody><tr>
    <td style="background:#264653;border-radius:4px;mso-padding-alt:12px 24px;">
      <a href="#" style="display:inline-block;text-decoration:none;padding:12px 24px;font-family:Arial, sans-serif;line-height:1.25;color:#ffffff;font-weight:bold;">Go</a>
    </td>
  </tr></tbody></table>
</td></tr></tbody></table>

<menu> / <item>

<menu><item href="#">Home</item></menu>

<callout>

<callout class="primary">Note</callout>

<spacer>

mso-line-height-rule:exactly keeps Outlook from inflating the gap. Supports size, size-sm, size-lg (responsive via .hide-for-large/.show-for-large).

<spacer size="16"></spacer>

<block-grid>

<block-grid up="4"></block-grid>

<wrapper>, <h-line>, <center>

  • <wrapper class="header"> → <table class="header wrapper" align="center" style="width:100%;"> with a .wrapper-inner cell.
  • <h-line> → a full-width single-cell table drawn as a 1px rule in the border color.
  • <center> renders a <center align="center" class="float-center"> and adds align="center" and .float-center to its direct children and nested menu items.

<raw>

Anything between <raw> and </raw> is passed through untouched (multi-line supported), including through the layout. Raw blocks cannot be nested.

<raw><% liquid_or_mso_conditional %></raw>

Token-driven built-ins: <cta> and <info-box>

Brand-neutral, token-styled components, registered by default.

<cta href="https://example.com">Go</cta>
<cta href="#" class="secondary">Also go</cta>

<cta> renders exactly like <button> with a cta class; it raises if href is missing. <info-box> is a callout styled from the info_box_* tokens.

Every component keeps the author's class and style (merged, never duplicated) and passes id, aria-*, data-* and other attributes through to its root element.

Custom components

Register your own tag with a class that inherits from ActiveMail::Components::Base and implements #transform(node, inner). You get the matched Nokogiri node (read its attributes) and inner, an opaque slot standing for the already-transformed children: interpolate it exactly once where the children belong, never parse or alter it (dropping or duplicating it raises ActiveMail::Error). Return the replacement markup string. The generator scaffolds one:

bin/rails g activemail:component Divider

The generator namespaces the class as Components::Divider (under app/mailers/components/); registering by tag, the namespace is yours to choose. A minimal hand-written equivalent:

class Divider < ActiveMail::Components::TableComponent
  def transform(node, inner)
    %(<table#{root_attributes(node, classes: 'divider', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
  end
end

ActiveMail.configuration.register_component('divider', Divider)

root_attributes emits the merged class, the merged style and every pass-through attribute; override consumed_attributes to list the attributes your component reads itself (they are not passed through). TableComponent already consumes the TABLE_RESET attributes (role, border, …) of a layout table root; inherit from Base for any other root element. Other helpers: class?, escape_attr, column_count, container_width.

Per-instance overrides (including replacing a built-in tag) are also possible:

ActiveMail::Core.new(components: { 'button' => MyButton }).transpile(source)

Generators

Generator Purpose
activemail:install Initializer + mailer layout + stylesheet wiring (works zero-config). --haml / --slim supported.
activemail:views Eject the default layout + partials for customization.
activemail:styles Eject the SCSS framework partials for customization.
activemail:component NAME Scaffold a component class + print its register snippet.

Testing & quality

An opt-in layer (never loaded by require 'activemail'). Require it from your test suite.

# Minitest — require the assertions module explicitly:
require 'activemail/quality/minitest'

class MailerTest < ActiveSupport::TestCase
  include ActiveMail::Quality::Minitest

  test 'welcome email is sound' do
    assert_email_quality(WelcomeMailer.welcome(user)) # a message, or an HTML string
  end
end

class EmailPreviewsTest < ActiveSupport::TestCase
  include ActiveMail::Quality::Minitest

  assert_quality_for_all_previews # one test per preview email and locale
end
# RSpec — require the matcher (registers be_a_valid_email when RSpec is loaded):
require 'activemail/quality/rspec'

expect(rendered_html).to be_a_valid_email

The Guard checks byte size (Gmail clips ~102 KB), parse errors, role="presentation" on every table, alt on every image, lang on full documents, a single <html>/<body>, missing translations, a non-blank <title>, an href on every link, unresolved {{placeholders}}, CSS colors clients ignore (rgb(… %), hsl(), color-mix(), var()), and — given a message — a plain text part free of HTML entities. A mailer that declines to send (NullMail) is a violation unless it is listed in allow_declined (assert_email_quality(mail, allow_declined: true) or be_a_valid_email(allow_declined: true) for one call). Built-in rules can be disabled and your own added; a single email can also be exempt from a rule (assert_email_quality(html, skip: %i[img_alt]), be_a_valid_email(skip: …), or rule_exemptions below) while every other rule still applies to it. A name the guard does not know raises an ArgumentError before any preview renders; a disabled rule is a known name.

signature = ActiveMail::Quality::Rule.html(:single_signature) do |doc, _html|
  'signature rendered twice' if doc.css('.signature').size > 1
end

ActiveMail::Quality.configure do |c|
  c.required_previews = %w[welcome_mailer#welcome]
  c.allow_declined = %w[reminder_mailer#maybe_skipped] # previews allowed to return a NullMail
  c.rule_exemptions = {                      # a preview name covers all its emails
    'legacy_mailer' => %i[img_alt],
    'welcome_mailer#welcome' => %i[single_signature]
  }
  c.guard = ActiveMail::Quality::Guard.new(max_bytes: 90_000, disable: %i[img_alt], rules: [signature])
  c.locales = %i[fr en]                      # or ->(preview) { [...] }; each preview gets params[:locale]
  c.formats = %i[html text]
  c.around_each = lambda do |_preview, _email, _locale, &render|
    ActiveRecord::Base.transaction { render.call; raise ActiveRecord::Rollback } # previews leave no data behind
  end
  c.output_filters = [[/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/, '<uuid>']] # applied to written files only
  c.output_dir = 'tmp/email_previews'        # relative, inside Rails.root
end

Rule.html blocks receive the parsed document and the raw HTML; Rule.message blocks receive the Mail::Message. Return nil, a message or an array of messages.

rake activemail:emails:render_all renders every ActionMailer preview (each locale and format) to output_dir for visual diffing and fails on any guard violation among required_previews. It only replaces its own .html/.txt files.

Email-client compatibility policy

Targets the real-world client landscape as of 2026:

  • Outlook for Windows (Word engine) — MSO ghost tables/cells and mso-* properties keep layouts intact.
  • Orange.fr (major FR webmail) — degraded but functional: the markup never depends on !important, border-radius, background-image, flex, or grid.
  • Gmail mobile — strips most <style>; critical layout is inlined, with enhancement CSS left in a <style> block.
  • Accessibility — role="presentation" on every layout table; provide alt text and sufficient contrast.

Programmatic use

ActiveMail.configuration.core.transpile('<container><row><columns>Hi</columns></row></container>')
ActiveMail::Pipeline.call(source) # transpile + inline
ActiveMail::Core.new(column_count: 24, container_width: 480).transpile(source)

Development

bundle install
bundle exec rake test     # minitest
bundle exec rubocop
bundle exec srb tc        # Sorbet

License

MIT. See LICENSE.txt, which retains the original ZURB copyright.