activemail 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +23 -0
- data/README.md +375 -0
- data/app/assets/stylesheets/active_mail/_active_mail_tokens.scss.erb +3 -0
- data/app/assets/stylesheets/active_mail/_components.scss +58 -0
- data/app/assets/stylesheets/active_mail/_dark.scss +123 -0
- data/app/assets/stylesheets/active_mail/_grid.scss +49 -0
- data/app/assets/stylesheets/active_mail/_settings.scss +25 -0
- data/app/assets/stylesheets/active_mail/_utilities.scss +47 -0
- data/app/assets/stylesheets/active_mail/active_mail.scss +44 -0
- data/app/helpers/active_mail/styles_helper.rb +29 -0
- data/app/views/layouts/active_mail/_footer.html.inky-erb +10 -0
- data/app/views/layouts/active_mail/_head.html.inky-erb +6 -0
- data/app/views/layouts/active_mail/mailer.html.inky-erb +35 -0
- data/lib/active_mail/components/base.rb +119 -0
- data/lib/active_mail/components/block_grid.rb +19 -0
- data/lib/active_mail/components/button.rb +39 -0
- data/lib/active_mail/components/callout.rb +23 -0
- data/lib/active_mail/components/center.rb +24 -0
- data/lib/active_mail/components/columns.rb +72 -0
- data/lib/active_mail/components/container.rb +25 -0
- data/lib/active_mail/components/cta.rb +36 -0
- data/lib/active_mail/components/h_line.rb +19 -0
- data/lib/active_mail/components/info_box.rb +32 -0
- data/lib/active_mail/components/inky.rb +18 -0
- data/lib/active_mail/components/menu.rb +22 -0
- data/lib/active_mail/components/menu_item.rb +21 -0
- data/lib/active_mail/components/row.rb +18 -0
- data/lib/active_mail/components/spacer.rb +45 -0
- data/lib/active_mail/components/wrapper.rb +21 -0
- data/lib/active_mail/configuration.rb +229 -0
- data/lib/active_mail/inliner/base.rb +29 -0
- data/lib/active_mail/inliner/interceptor.rb +51 -0
- data/lib/active_mail/inliner/null.rb +23 -0
- data/lib/active_mail/inliner/premailer.rb +22 -0
- data/lib/active_mail/inliner/roadie.rb +30 -0
- data/lib/active_mail/libxml.rb +8 -0
- data/lib/active_mail/parse_error_reporter.rb +47 -0
- data/lib/active_mail/quality/configuration.rb +56 -0
- data/lib/active_mail/quality/guard.rb +131 -0
- data/lib/active_mail/quality/minitest.rb +64 -0
- data/lib/active_mail/quality/preview_renderer.rb +70 -0
- data/lib/active_mail/quality/render_all.rb +90 -0
- data/lib/active_mail/quality/rspec.rb +65 -0
- data/lib/active_mail/quality.rb +38 -0
- data/lib/active_mail/rails/compiled_stylesheet.rb +62 -0
- data/lib/active_mail/rails/engine.rb +36 -0
- data/lib/active_mail/rails/template_handler.rb +62 -0
- data/lib/active_mail/tokens.rb +139 -0
- data/lib/active_mail/version.rb +6 -0
- data/lib/active_mail.rb +161 -0
- data/lib/activemail.rb +5 -0
- data/lib/generators/active_mail/component_generator.rb +31 -0
- data/lib/generators/active_mail/install_generator.rb +59 -0
- data/lib/generators/active_mail/styles_generator.rb +30 -0
- data/lib/generators/active_mail/templates/component.rb.tt +13 -0
- data/lib/generators/active_mail/templates/initializer.rb +32 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-erb +30 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-haml +17 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-slim +16 -0
- data/lib/generators/active_mail/views_generator.rb +16 -0
- data/lib/tasks/active_mail.rake +36 -0
- metadata +151 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dc582cd5c5cb4c6106a7621144e529eb4b52ba2e89abcea488698c7423f4bf5b
|
|
4
|
+
data.tar.gz: ab394297c1480f88fa5c265f7865fd4ea84118715871e06a2d5b0b9205e68dfe
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c0ff68ee11f5a0b880e5eaa5c9f07d41ae163a5863bd8d1fcd26be1a60ad79a438d7ddc0130122411426cb23083e3c46a07092eb94ef89798600b51046032209
|
|
7
|
+
data.tar.gz: a5f8948b1410fbee84666b37f7b3dfa4358cc72b4cab87404fba8a9638e56e3534bb0492c0103eadde4a4158f6009bc383aba5b620216329e6cc20870254ddc2
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2026-06-13
|
|
9
|
+
|
|
10
|
+
First release of **ActiveMail**, an opinionated, plug & play responsive email
|
|
11
|
+
toolkit for Rails. The markup engine derives from `inky-rb` v2 (rebranded to the
|
|
12
|
+
`ActiveMail` namespace); the batteries-included framework layer is new.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Semantic markup engine.** Simple tags (`<container>`, `<row>`, `<columns>`,
|
|
17
|
+
`<button>`, `<menu>`, `<callout>`, `<spacer>`, `<wrapper>`...) transpile to
|
|
18
|
+
bulletproof, email-client-ready table markup.
|
|
19
|
+
- **Extensible component registry (open/closed).** Each component is a class
|
|
20
|
+
(`ActiveMail::Components::*`) inheriting from `ActiveMail::Components::Base`.
|
|
21
|
+
Register your own tags with
|
|
22
|
+
`ActiveMail.configuration.register_component('my-tag', MyComponent)`; custom
|
|
23
|
+
components receive the matched Nokogiri node and full DOM access.
|
|
24
|
+
- **`role="presentation"` on every generated layout table** for accessibility.
|
|
25
|
+
- **MSO ghost tables/cells** around `<container>`, `<row>`/`<columns>`, so the
|
|
26
|
+
fluid-hybrid layout still renders as a grid in Outlook (Word engine).
|
|
27
|
+
- **`container_width` configuration** (default `600`), global or per
|
|
28
|
+
`ActiveMail::Core.new(container_width:)`.
|
|
29
|
+
- **Bulletproof `<button>`**: padding carried by the `<a>` so the whole button is
|
|
30
|
+
clickable.
|
|
31
|
+
- **`mso-line-height-rule:exactly`** on `<spacer>` to stop Outlook inflating it.
|
|
32
|
+
- **Multi-line `<raw>` support.**
|
|
33
|
+
- **`on_parse_error`** (`:ignore`/`:warn`/`:raise`) surfaces HTML the parser had
|
|
34
|
+
to repair instead of silently shipping a different email.
|
|
35
|
+
- **Sorbet `# typed: strict`** across `lib/`, with full signatures.
|
|
36
|
+
- **Minitest suite** with golden-markup assertions for every component, error and
|
|
37
|
+
edge cases, and the Rails template-handler integration path.
|
|
38
|
+
- **GitHub Actions CI**: Ruby 3.2/3.3/3.4/4.0 × Rails 7.1/8.0/8.1.
|
|
39
|
+
|
|
40
|
+
### Markup notes
|
|
41
|
+
|
|
42
|
+
- **Fluid-hybrid layout.** `<columns>` use `display:inline-block` with a pixel
|
|
43
|
+
`max-width` so columns stack naturally on small screens without a media query,
|
|
44
|
+
and are restored to a grid in Outlook via ghost cells. The `small-*`, `large-*`,
|
|
45
|
+
`first`, `last` classes are preserved for media-query enhancement. Ghost-cell
|
|
46
|
+
widths are `container_width × large / column_count`, capped at `container_width`,
|
|
47
|
+
with **no gutter model** — add padding inside columns for gutters.
|
|
48
|
+
- All generated tables carry explicit `border="0" cellpadding="0" cellspacing="0"`
|
|
49
|
+
and inline `style` (no reliance on `!important` or `border-radius`, both stripped
|
|
50
|
+
by Orange.fr webmail).
|
|
51
|
+
- The engine emits no hard-coded colors, so app-side dark mode
|
|
52
|
+
(`prefers-color-scheme`, `[data-ogsc]`) works unhindered.
|
|
53
|
+
|
|
54
|
+
### Compatibility
|
|
55
|
+
|
|
56
|
+
- Ruby `>= 3.2` (tested up to 4.0).
|
|
57
|
+
- Rails `>= 7.1` (tested up to 8.1).
|
|
58
|
+
- Nokogiri `>= 1.16`.
|
|
59
|
+
|
|
60
|
+
[1.0.0]: https://github.com/AdVitam/activemail/releases/tag/v1.0.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Copyright (c) 2013-2018 ZURB, inc.
|
|
2
|
+
Copyright (c) 2026 Advitam
|
|
3
|
+
|
|
4
|
+
MIT License
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
7
|
+
a copy of this software and associated documentation files (the
|
|
8
|
+
"Software"), to deal in the Software without restriction, including
|
|
9
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
10
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
11
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
12
|
+
the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be
|
|
15
|
+
included in all copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
18
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
19
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
20
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
21
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
22
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
23
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# ActiveMail
|
|
2
|
+
|
|
3
|
+
**Opinionated, plug & play responsive email for Rails.** ActiveMail turns simple,
|
|
4
|
+
semantic tags into the bulletproof table markup email clients require, and ships a
|
|
5
|
+
batteries-included layer on top — a themeable SCSS framework, dark mode, design
|
|
6
|
+
tokens, automatic CSS inlining, generators, and test-time quality guards — so a
|
|
7
|
+
responsive, accessible email renders **out of the box**, with every default
|
|
8
|
+
overridable.
|
|
9
|
+
|
|
10
|
+
> Not affiliated with Rails core. The name echoes the `Active*` family by
|
|
11
|
+
> convention only.
|
|
12
|
+
|
|
13
|
+
Write this:
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<container>
|
|
17
|
+
<row>
|
|
18
|
+
<columns large="6">Left</columns>
|
|
19
|
+
<columns large="6">Right</columns>
|
|
20
|
+
</row>
|
|
21
|
+
</container>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
and ActiveMail produces a fluid-hybrid, Outlook-safe table layout with MSO ghost
|
|
25
|
+
tables, `role="presentation"` on every table, and inline styles — markup that
|
|
26
|
+
renders consistently from Apple Mail to Outlook (Word engine) to Gmail mobile.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Semantic markup** → bulletproof tables (`<container>`, `<row>`, `<columns>`,
|
|
31
|
+
`<button>`, `<menu>`, `<callout>`, `<spacer>`, …), with an extensible
|
|
32
|
+
open/closed component registry.
|
|
33
|
+
- **Design tokens** as the single Ruby source of truth, bridged to SCSS.
|
|
34
|
+
- **Themeable SCSS framework** with built-in **dark mode** (`prefers-color-scheme`
|
|
35
|
+
+ Outlook `[data-ogsc]`).
|
|
36
|
+
- **Automatic CSS inlining** via a pluggable adapter (premailer by default, roadie
|
|
37
|
+
optional, or your own).
|
|
38
|
+
- **Generators** to install, eject views/styles, and scaffold components.
|
|
39
|
+
- **Opt-in quality layer**: a Guard (size, `role`, `alt`, `lang`), preview
|
|
40
|
+
renderer, Minitest assertions + RSpec matcher, and a `render_all` rake task.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Gemfile
|
|
46
|
+
gem 'activemail'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bundle install
|
|
51
|
+
bin/rails g active_mail:install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The generator drops a commented initializer, a mailer layout, and wires the
|
|
55
|
+
framework stylesheet. It works zero-config; customize only what you want.
|
|
56
|
+
|
|
57
|
+
Name a mailer view `welcome.html.inky` (or compose with another engine:
|
|
58
|
+
`welcome.html.inky-erb`, `.inky-slim`, `.inky-haml`) and it is transpiled on
|
|
59
|
+
render. CSS is inlined automatically before delivery.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# config/initializers/active_mail.rb
|
|
65
|
+
ActiveMail.configure do |config|
|
|
66
|
+
config.template_engine = :erb # underlying engine for `.html.inky` (default :erb)
|
|
67
|
+
config.column_count = 12 # grid columns (default 12)
|
|
68
|
+
config.container_width = 600 # px width of <container> + MSO ghost table (default 600)
|
|
69
|
+
config.on_parse_error = :warn # :ignore, :warn or :raise (default :warn)
|
|
70
|
+
|
|
71
|
+
# CSS inliner: :premailer (default), :roadie, :null, or a custom
|
|
72
|
+
# ActiveMail::Inliner::Base subclass/instance.
|
|
73
|
+
config.inliner = :premailer
|
|
74
|
+
# Set false if another inliner (e.g. premailer-rails) already runs on mailers;
|
|
75
|
+
# `config.inliner = :null` also fully short-circuits ActiveMail's interceptor.
|
|
76
|
+
config.register_inline_interceptor = true
|
|
77
|
+
|
|
78
|
+
# Design tokens (see below).
|
|
79
|
+
config.tokens.color :primary, '#2a9d8f'
|
|
80
|
+
|
|
81
|
+
# Custom components (see "Custom components").
|
|
82
|
+
config.register_component 'cta', ActiveMail::Components::Cta
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`on_parse_error` surfaces HTML the parser had to repair (unclosed/mismatched
|
|
87
|
+
tags, broken attributes) instead of silently sending a different email than
|
|
88
|
+
intended. `:warn` logs via `Rails.logger` when available (else `$stderr`); use
|
|
89
|
+
`:raise` in CI/staging to fail the build on malformed templates. Registered
|
|
90
|
+
component tags never trigger it. The parser knows HTML4 — HTML5-only tags
|
|
91
|
+
(`<section>`, …) are reported as unknown; register them as components or use
|
|
92
|
+
`:ignore`.
|
|
93
|
+
|
|
94
|
+
## Design tokens
|
|
95
|
+
|
|
96
|
+
Tokens are the single source of truth. Declare them **once in Ruby**; ActiveMail
|
|
97
|
+
bridges them to SCSS automatically (`$am-color-primary`, `$am-font-body`,
|
|
98
|
+
`$am-spacing-md`, …) so a component's inline color always matches the stylesheet.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
config.tokens.color :primary, '#2a9d8f'
|
|
102
|
+
config.tokens.color :secondary, '#264653'
|
|
103
|
+
config.tokens.font :heading, 'Georgia, serif'
|
|
104
|
+
config.tokens.spacing :lg, '32px'
|
|
105
|
+
|
|
106
|
+
ActiveMail.tokens.color(:primary) # => "#2a9d8f"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Defaults are neutral (a calm teal `primary`, near-black `text`, white
|
|
110
|
+
`background`, …) and fully overridable. Under Sprockets the SCSS bridge is a
|
|
111
|
+
preprocessed partial; under Propshaft run `rake active_mail:tokens:export` to
|
|
112
|
+
materialize a static `_active_mail_tokens.scss`.
|
|
113
|
+
|
|
114
|
+
## Styling
|
|
115
|
+
|
|
116
|
+
The framework stylesheet lives at `active_mail/active_mail` and is themed entirely
|
|
117
|
+
by tokens — no hard-coded brand colors. It includes a fluid-hybrid grid, component
|
|
118
|
+
hooks (`.button`, `.cta`, `.callout`, `.spacer`, …), utilities, and dark mode.
|
|
119
|
+
|
|
120
|
+
Override at three levels, cheapest first:
|
|
121
|
+
|
|
122
|
+
1. **Tokens** (Ruby) — covers most theming.
|
|
123
|
+
2. **`bin/rails g active_mail:styles`** — eject the SCSS partials into your app to
|
|
124
|
+
edit them; your copies shadow the gem's.
|
|
125
|
+
3. **`bin/rails g active_mail:views`** — eject the default layout + partials
|
|
126
|
+
(`app/views/layouts/active_mail/*`); a same-named file in your app wins by
|
|
127
|
+
Rails path precedence. Put your logo/header/footer here — those are the app's
|
|
128
|
+
identity, not the gem's.
|
|
129
|
+
|
|
130
|
+
Dark mode ships on: a `<style>` block keys off `prefers-color-scheme: dark` (Apple
|
|
131
|
+
Mail/iOS) and Outlook's `[data-ogsc]`, with surfaces derived from your tokens.
|
|
132
|
+
|
|
133
|
+
## CSS inlining
|
|
134
|
+
|
|
135
|
+
Email clients (Gmail mobile, Orange.fr) strip `<style>`, so critical CSS must be
|
|
136
|
+
inlined. ActiveMail registers an ActionMailer interceptor that runs the configured
|
|
137
|
+
inliner on every outgoing HTML part — your `<style>` enhancement block (media
|
|
138
|
+
queries, dark mode) is preserved.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
config.inliner = :premailer # default, hard dependency
|
|
142
|
+
config.inliner = :roadie # add `gem 'roadie'` yourself
|
|
143
|
+
config.inliner = :null # opt out (e.g. you run premailer-rails)
|
|
144
|
+
config.inliner = MyInliner.new # any ActiveMail::Inliner::Base
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Components
|
|
148
|
+
|
|
149
|
+
Every built-in tag and its rendered output. Tables omit
|
|
150
|
+
`border/cellpadding/cellspacing/role` below for brevity — they are always present.
|
|
151
|
+
|
|
152
|
+
### `<container>`
|
|
153
|
+
|
|
154
|
+
Fluid-hybrid wrapper, capped at `container_width`, wrapped in an MSO ghost table.
|
|
155
|
+
|
|
156
|
+
```html
|
|
157
|
+
<container>...</container>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```html
|
|
161
|
+
<!--[if mso | IE]><table role="presentation" align="center" width="600">...<![endif]-->
|
|
162
|
+
<table class="container" align="center" style="width:100%;max-width:600px;margin:0 auto;">
|
|
163
|
+
<tbody><tr><td>...</td></tr></tbody>
|
|
164
|
+
</table>
|
|
165
|
+
<!--[if mso | IE]></td></tr></table><![endif]-->
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### `<row>` / `<columns>`
|
|
169
|
+
|
|
170
|
+
Columns use `display:inline-block` + pixel `max-width` (natural stacking on
|
|
171
|
+
mobile) with MSO ghost cells restoring the grid in Outlook. `small`/`large`
|
|
172
|
+
attributes and `first`/`last`/`small-*`/`large-*` classes are preserved for
|
|
173
|
+
media-query enhancement.
|
|
174
|
+
|
|
175
|
+
```html
|
|
176
|
+
<row><columns large="6">Hi</columns></row>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Column widths are computed as `container_width × large / column_count`, capped
|
|
180
|
+
at `container_width`, **with no gutter model**: two `large="6"` columns sit
|
|
181
|
+
edge-to-edge (300px + 300px in a 600px container). Add padding inside your
|
|
182
|
+
columns for gutters, and keep the `large` sizes of a row summing to at most
|
|
183
|
+
`column_count`, otherwise the ghost cells will wrap in Outlook.
|
|
184
|
+
|
|
185
|
+
### `<button>`
|
|
186
|
+
|
|
187
|
+
Bulletproof button: the padding lives on the `<a>` so the whole control is
|
|
188
|
+
clickable. Variant classes (`primary`, `expand`, …) are preserved.
|
|
189
|
+
|
|
190
|
+
```html
|
|
191
|
+
<button href="#">Go</button>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`<button class="expand">` adds a centered link and an `.expander` cell.
|
|
195
|
+
|
|
196
|
+
### `<menu>` / `<item>`
|
|
197
|
+
|
|
198
|
+
```html
|
|
199
|
+
<menu><item href="#">Home</item></menu>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `<callout>`
|
|
203
|
+
|
|
204
|
+
```html
|
|
205
|
+
<callout class="primary">Note</callout>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### `<spacer>`
|
|
209
|
+
|
|
210
|
+
`mso-line-height-rule:exactly` keeps Outlook from inflating the gap. Supports
|
|
211
|
+
`size`, `size-sm`, `size-lg` (responsive via `.hide-for-large`/`.show-for-large`).
|
|
212
|
+
|
|
213
|
+
```html
|
|
214
|
+
<spacer size="16"></spacer>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### `<block-grid>`
|
|
218
|
+
|
|
219
|
+
```html
|
|
220
|
+
<block-grid up="4"></block-grid>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### `<wrapper>`, `<h-line>`, `<center>`
|
|
224
|
+
|
|
225
|
+
- `<wrapper class="header">` → `<table class="header wrapper" align="center" style="width:100%;">` with a `.wrapper-inner` cell.
|
|
226
|
+
- `<h-line>` → a full-width single-cell table for a horizontal rule.
|
|
227
|
+
- `<center>` adds `align="center"` and `.float-center` to its element children (and `.float-center` to nested menu items).
|
|
228
|
+
|
|
229
|
+
### `<inky>`
|
|
230
|
+
|
|
231
|
+
Renders a bare `<tr>`, useful inside hand-written tables.
|
|
232
|
+
|
|
233
|
+
### `<raw>`
|
|
234
|
+
|
|
235
|
+
Anything between `<raw>` and `</raw>` is passed through untouched (multi-line
|
|
236
|
+
supported). Raw blocks cannot be nested.
|
|
237
|
+
|
|
238
|
+
```html
|
|
239
|
+
<raw><% liquid_or_mso_conditional %></raw>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Token-driven built-ins: `<cta>` and `<info-box>`
|
|
243
|
+
|
|
244
|
+
Brand-neutral, token-styled components shipped with the gem but **not registered
|
|
245
|
+
by default** (so they never collide with your tags). Register them to use:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
config.register_component 'cta', ActiveMail::Components::Cta
|
|
249
|
+
config.register_component 'info-box', ActiveMail::Components::InfoBox
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
```html
|
|
253
|
+
<cta href="https://example.com">Go</cta>
|
|
254
|
+
<cta href="#" class="secondary">Also go</cta>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`<cta>` renders a bulletproof button using `tokens.color(:primary)` (or
|
|
258
|
+
`:secondary` with `class="secondary"`); it raises if `href` is missing.
|
|
259
|
+
|
|
260
|
+
## Custom components
|
|
261
|
+
|
|
262
|
+
Register your own tag with a class that inherits from
|
|
263
|
+
`ActiveMail::Components::Base` and implements `#transform(node, inner)`. You get
|
|
264
|
+
the matched Nokogiri node (full DOM access) and the already-transformed inner
|
|
265
|
+
HTML; return the replacement markup string. The generator scaffolds one:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
bin/rails g active_mail:component Divider
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The generator namespaces the class as `Components::Divider` (under
|
|
272
|
+
`app/mailers/components/`); registering by tag, the namespace is yours to choose.
|
|
273
|
+
A minimal hand-written equivalent:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
class Divider < ActiveMail::Components::Base
|
|
277
|
+
def transform(node, _inner)
|
|
278
|
+
klass = combine_classes(node, 'divider')
|
|
279
|
+
%(<table class="#{klass}" #{TABLE_RESET} style="width:100%;"><tbody><tr><td></td></tr></tbody></table>)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
ActiveMail.configuration.register_component('divider', Divider)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Helpers available from `Base`: `combine_classes`, `combine_attributes`,
|
|
287
|
+
`pass_through_attributes`, `class?`, `target_attribute`, `escape_attr`,
|
|
288
|
+
`style_attribute`, `column_count`, `container_width`.
|
|
289
|
+
|
|
290
|
+
Per-instance overrides (including replacing a built-in tag) are also possible:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
ActiveMail::Core.new(components: { 'button' => MyButton }).release_the_kraken(source)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Generators
|
|
297
|
+
|
|
298
|
+
| Generator | Purpose |
|
|
299
|
+
|---|---|
|
|
300
|
+
| `active_mail:install` | Initializer + mailer layout + stylesheet wiring (works zero-config). `--haml` / `--slim` supported. |
|
|
301
|
+
| `active_mail:views` | Eject the default layout + partials for customization. |
|
|
302
|
+
| `active_mail:styles` | Eject the SCSS framework partials for customization. |
|
|
303
|
+
| `active_mail:component NAME` | Scaffold a component class + print its register snippet. |
|
|
304
|
+
|
|
305
|
+
## Testing & quality
|
|
306
|
+
|
|
307
|
+
An **opt-in** layer (never loaded by `require 'active_mail'`). Require it from your
|
|
308
|
+
test suite.
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
# Minitest — require the assertions module explicitly:
|
|
312
|
+
require 'active_mail/quality/minitest'
|
|
313
|
+
|
|
314
|
+
class MailerTest < ActiveSupport::TestCase
|
|
315
|
+
include ActiveMail::Quality::Minitest
|
|
316
|
+
|
|
317
|
+
test 'welcome email is sound' do
|
|
318
|
+
assert_email_quality(rendered_html)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
# RSpec — require the matcher (registers be_a_valid_email when RSpec is loaded):
|
|
325
|
+
require 'active_mail/quality/rspec'
|
|
326
|
+
|
|
327
|
+
expect(rendered_html).to be_a_valid_email
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The Guard checks byte size (Gmail clips ~102 KB), `role="presentation"` on every
|
|
331
|
+
table, `alt` on every image, and `lang` on full documents — all thresholds
|
|
332
|
+
configurable:
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
ActiveMail::Quality.configure do |c|
|
|
336
|
+
c.required_previews = %w[welcome_mailer#welcome]
|
|
337
|
+
c.guard = ActiveMail::Quality::Guard.new(max_bytes: 90_000)
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`rake active_mail:emails:render_all` renders every ActionMailer preview to disk
|
|
342
|
+
for visual diffing and fails on any guard violation among `required_previews`.
|
|
343
|
+
|
|
344
|
+
## Email-client compatibility policy
|
|
345
|
+
|
|
346
|
+
Targets the real-world client landscape as of 2026:
|
|
347
|
+
|
|
348
|
+
- **Outlook for Windows (Word engine)** — MSO ghost tables/cells and `mso-*`
|
|
349
|
+
properties keep layouts intact.
|
|
350
|
+
- **Orange.fr (major FR webmail)** — degraded but functional: the markup never
|
|
351
|
+
*depends* on `!important`, `border-radius`, `background-image`, flex, or grid.
|
|
352
|
+
- **Gmail mobile** — strips most `<style>`; critical layout is inlined, with
|
|
353
|
+
enhancement CSS left in a `<style>` block.
|
|
354
|
+
- **Accessibility** — `role="presentation"` on every layout table; provide `alt`
|
|
355
|
+
text and sufficient contrast.
|
|
356
|
+
|
|
357
|
+
## Programmatic use
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
ActiveMail::Core.new.release_the_kraken('<container><row><columns>Hi</columns></row></container>')
|
|
361
|
+
ActiveMail::Core.new(column_count: 24, container_width: 480).release_the_kraken(source)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Development
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
bundle install
|
|
368
|
+
bundle exec rake test # minitest
|
|
369
|
+
bundle exec rubocop
|
|
370
|
+
bundle exec srb tc # Sorbet
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## License
|
|
374
|
+
|
|
375
|
+
MIT. See [`LICENSE.txt`](LICENSE.txt).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Background on both inner cell and link so the whole bulletproof button
|
|
2
|
+
// (table.button > td > table > td > a) is filled and clickable.
|
|
3
|
+
.button table td {
|
|
4
|
+
background: $am-button-primary-bg;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.button a {
|
|
8
|
+
color: $am-button-color;
|
|
9
|
+
background: $am-button-primary-bg;
|
|
10
|
+
font-family: $am-font-family;
|
|
11
|
+
font-weight: bold;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.button.secondary table td,
|
|
15
|
+
.button.secondary a {
|
|
16
|
+
background: $am-button-secondary-bg;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Progressive enhancement only — some clients strip border-radius.
|
|
20
|
+
.button.radius table td,
|
|
21
|
+
.button.radius a {
|
|
22
|
+
border-radius: $am-button-radius;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// CTA mirrors the bulletproof button; colors are also inlined by the component.
|
|
26
|
+
.cta table td {
|
|
27
|
+
background: $am-button-primary-bg;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.cta a {
|
|
31
|
+
color: $am-button-color;
|
|
32
|
+
background: $am-button-primary-bg;
|
|
33
|
+
font-family: $am-font-family;
|
|
34
|
+
font-weight: bold;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.cta.secondary table td,
|
|
38
|
+
.cta.secondary a {
|
|
39
|
+
background: $am-button-secondary-bg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.info-box td {
|
|
43
|
+
background-color: $am-background;
|
|
44
|
+
border-left: 5px solid $am-border;
|
|
45
|
+
color: $am-text;
|
|
46
|
+
padding: $am-spacing-md;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.callout-inner {
|
|
50
|
+
background-color: $am-background;
|
|
51
|
+
color: $am-text;
|
|
52
|
+
padding: $am-spacing-md;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Spacer hook — height is set inline per instance; this is a styling anchor.
|
|
56
|
+
.spacer {
|
|
57
|
+
width: 100%;
|
|
58
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Dark mode: prefers-color-scheme (Apple Mail/iOS) + [data-ogsc] (Outlook
|
|
2
|
+
// Android). Colors derived from tokens — pure black/white crush contrast.
|
|
3
|
+
|
|
4
|
+
$am-dark-bg: mix($am-color-text, $am-color-background, 96%) !default;
|
|
5
|
+
$am-dark-surface: mix($am-color-text, $am-color-background, 92%) !default;
|
|
6
|
+
$am-dark-panel: mix($am-color-text, $am-color-background, 82%) !default;
|
|
7
|
+
$am-dark-text: mix($am-color-background, $am-color-text, 88%) !default;
|
|
8
|
+
$am-dark-muted: $am-color-muted !default;
|
|
9
|
+
$am-dark-link: $am-color-primary !default;
|
|
10
|
+
|
|
11
|
+
@mixin am-dark-rules {
|
|
12
|
+
.body,
|
|
13
|
+
body {
|
|
14
|
+
background-color: $am-dark-bg !important;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.container,
|
|
18
|
+
.wrapper-inner {
|
|
19
|
+
background-color: $am-dark-surface !important;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
body,
|
|
23
|
+
p,
|
|
24
|
+
li,
|
|
25
|
+
td,
|
|
26
|
+
th,
|
|
27
|
+
h1,
|
|
28
|
+
h2,
|
|
29
|
+
h3,
|
|
30
|
+
h4,
|
|
31
|
+
h5,
|
|
32
|
+
h6 {
|
|
33
|
+
color: $am-dark-text !important;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
a {
|
|
37
|
+
color: $am-dark-link !important;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.button table td,
|
|
41
|
+
.button a,
|
|
42
|
+
.cta table td,
|
|
43
|
+
.cta a {
|
|
44
|
+
background: $am-color-primary !important;
|
|
45
|
+
color: $am-color-button-text !important;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Keep the secondary variant's color — the primary rule above would erase it.
|
|
49
|
+
.button.secondary table td,
|
|
50
|
+
.button.secondary a,
|
|
51
|
+
.cta.secondary table td,
|
|
52
|
+
.cta.secondary a {
|
|
53
|
+
background: $am-color-secondary !important;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Panels keep their inline background unless overridden here, which would
|
|
57
|
+
// otherwise leave dark text on a dark background.
|
|
58
|
+
.callout-inner,
|
|
59
|
+
.info-box,
|
|
60
|
+
.info-box td {
|
|
61
|
+
background-color: $am-dark-panel !important;
|
|
62
|
+
border-left-color: $am-dark-muted !important;
|
|
63
|
+
color: $am-dark-text !important;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@media (prefers-color-scheme: dark) {
|
|
68
|
+
@include am-dark-rules;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Always-true feature query: inliners drop bare [data-ogsc] rules (no element
|
|
72
|
+
// matches) but preserve conditioned media queries.
|
|
73
|
+
@media screen and (max-width: 9999px) {
|
|
74
|
+
[data-ogsc] .body,
|
|
75
|
+
body[data-ogsc] {
|
|
76
|
+
background-color: $am-dark-bg !important;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
[data-ogsc] .container,
|
|
80
|
+
[data-ogsc] .wrapper-inner {
|
|
81
|
+
background-color: $am-dark-surface !important;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
[data-ogsc] p,
|
|
85
|
+
[data-ogsc] li,
|
|
86
|
+
[data-ogsc] td,
|
|
87
|
+
[data-ogsc] th,
|
|
88
|
+
[data-ogsc] h1,
|
|
89
|
+
[data-ogsc] h2,
|
|
90
|
+
[data-ogsc] h3,
|
|
91
|
+
[data-ogsc] h4,
|
|
92
|
+
[data-ogsc] h5,
|
|
93
|
+
[data-ogsc] h6 {
|
|
94
|
+
color: $am-dark-text !important;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[data-ogsc] a {
|
|
98
|
+
color: $am-dark-link !important;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
[data-ogsc] .button table td,
|
|
102
|
+
[data-ogsc] .button a,
|
|
103
|
+
[data-ogsc] .cta table td,
|
|
104
|
+
[data-ogsc] .cta a {
|
|
105
|
+
background: $am-color-primary !important;
|
|
106
|
+
color: $am-color-button-text !important;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
[data-ogsc] .button.secondary table td,
|
|
110
|
+
[data-ogsc] .button.secondary a,
|
|
111
|
+
[data-ogsc] .cta.secondary table td,
|
|
112
|
+
[data-ogsc] .cta.secondary a {
|
|
113
|
+
background: $am-color-secondary !important;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
[data-ogsc] .callout-inner,
|
|
117
|
+
[data-ogsc] .info-box,
|
|
118
|
+
[data-ogsc] .info-box td {
|
|
119
|
+
background-color: $am-dark-panel !important;
|
|
120
|
+
border-left-color: $am-dark-muted !important;
|
|
121
|
+
color: $am-dark-text !important;
|
|
122
|
+
}
|
|
123
|
+
}
|