activemail 1.2.3 → 2.1.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -2
  3. data/README.md +141 -56
  4. data/UPGRADING.md +185 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +44 -24
  7. data/app/assets/stylesheets/activemail/_grid.scss +40 -14
  8. data/app/assets/stylesheets/activemail/_settings.scss +23 -10
  9. data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
  10. data/app/assets/stylesheets/activemail/activemail.scss +6 -5
  11. data/app/helpers/active_mail/styles_helper.rb +14 -15
  12. data/app/views/layouts/activemail/_document_head.html.erb +18 -0
  13. data/app/views/layouts/activemail/mailer.html.inky-erb +1 -22
  14. data/lib/activemail/asset_pipeline/base.rb +47 -0
  15. data/lib/activemail/asset_pipeline/null.rb +19 -0
  16. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  17. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  18. data/lib/activemail/asset_pipeline.rb +35 -0
  19. data/lib/activemail/components/base.rb +35 -39
  20. data/lib/activemail/components/block_grid.rb +11 -4
  21. data/lib/activemail/components/button.rb +5 -28
  22. data/lib/activemail/components/button_renderer.rb +69 -0
  23. data/lib/activemail/components/callout.rb +4 -5
  24. data/lib/activemail/components/center.rb +10 -10
  25. data/lib/activemail/components/columns.rb +58 -15
  26. data/lib/activemail/components/container.rb +13 -6
  27. data/lib/activemail/components/cta.rb +9 -33
  28. data/lib/activemail/components/h_line.rb +3 -5
  29. data/lib/activemail/components/info_box.rb +6 -12
  30. data/lib/activemail/components/menu.rb +3 -4
  31. data/lib/activemail/components/menu_item.rb +10 -3
  32. data/lib/activemail/components/row.rb +3 -4
  33. data/lib/activemail/components/spacer.rb +16 -12
  34. data/lib/activemail/components/table_component.rb +23 -0
  35. data/lib/activemail/components/wrapper.rb +10 -4
  36. data/lib/activemail/configuration.rb +43 -57
  37. data/lib/activemail/errors.rb +15 -0
  38. data/lib/activemail/inliner/base.rb +2 -10
  39. data/lib/activemail/inliner/null.rb +0 -5
  40. data/lib/activemail/inliner/premailer/document.rb +100 -0
  41. data/lib/activemail/inliner/premailer.rb +3 -2
  42. data/lib/activemail/inliner/roadie.rb +1 -1
  43. data/lib/activemail/inliner.rb +33 -0
  44. data/lib/activemail/parse_error_reporter.rb +5 -4
  45. data/lib/activemail/pipeline.rb +26 -0
  46. data/lib/activemail/quality/builtin_message_rules.rb +58 -0
  47. data/lib/activemail/quality/builtin_rules.rb +165 -0
  48. data/lib/activemail/quality/configuration.rb +103 -8
  49. data/lib/activemail/quality/guard.rb +61 -83
  50. data/lib/activemail/quality/minitest.rb +56 -27
  51. data/lib/activemail/quality/output_dir.rb +52 -0
  52. data/lib/activemail/quality/output_filter.rb +24 -0
  53. data/lib/activemail/quality/preview_renderer.rb +46 -33
  54. data/lib/activemail/quality/render_all.rb +95 -39
  55. data/lib/activemail/quality/rspec.rb +14 -13
  56. data/lib/activemail/quality/rule.rb +47 -0
  57. data/lib/activemail/quality/rule_exemptions.rb +49 -0
  58. data/lib/activemail/rails/engine.rb +13 -13
  59. data/lib/activemail/rails/rendering.rb +51 -0
  60. data/lib/activemail/rails/template_handler.rb +3 -1
  61. data/lib/activemail/tokens/button_style.rb +1 -2
  62. data/lib/activemail/tokens/defaults.rb +78 -0
  63. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  64. data/lib/activemail/tokens.rb +19 -58
  65. data/lib/activemail/version.rb +1 -1
  66. data/lib/activemail.rb +89 -61
  67. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  68. data/lib/generators/activemail/templates/initializer.rb +0 -3
  69. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -19
  70. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -11
  71. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -10
  72. data/lib/tasks/activemail.rake +4 -5
  73. metadata +22 -4
  74. data/lib/activemail/components/inky.rb +0 -18
  75. data/lib/activemail/inliner/interceptor.rb +0 -51
  76. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7db4250b0c1551e5d419e5f6c22a6001253c0f8531e616f35ec03ac327ffa6d
4
- data.tar.gz: 9e514b570fca4bf16afb4110cb9ee3d10713306626e93f3a3fc435d729b7bef3
3
+ metadata.gz: 3dc3ba94cb3bbe53858813d40a054636f6b445a39fa7c52fcd0a8839e1fb6af0
4
+ data.tar.gz: 0a9f87dd6be3ed968f7a24d0110e0a59d52074d0a582ee8dd69ee430bf126784
5
5
  SHA512:
6
- metadata.gz: 28ace5f87d6884920b5f99072306d693b7fcb61b7ee0c1162b8d498c33a67a5b54f63ef1e61277d92523ebc1433fc281254d959ed88e914c2ccc60157c19ac25
7
- data.tar.gz: 67e448df315fae10c0faa563da2e0c7c5da4af21f03e0e801ed8018f44e981dcd9e1a881dd3038dd637a4a30a40ce40973ad178dd181ff4b6358d6d6438b5635
6
+ metadata.gz: 71a238775cdcfee13478fded9dee238e7abc374bd481ff0a8e4995616f329d6490efe939b0fc4bd4d8ae4fb8ab2b8622f44b162aa89c94fb1152570d0f1a8a9b
7
+ data.tar.gz: ef6f072e79dd25da4149fd47db1abbc33b0c97afd198835c97d36b5f1624e23e2245affef488fc931b323ba3e1a9842a45f3177c4b14abf1740d9121d351da42
data/CHANGELOG.md CHANGED
@@ -5,6 +5,110 @@ All notable changes to this project are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.1.0] - 2026-10-19
9
+
10
+ Behaviour changes to review before upgrading: see [UPGRADING.md](UPGRADING.md).
11
+
12
+ ### Added
13
+
14
+ - `container_gutter` spacing token (16px) and `$am-container-gutter`: the first
15
+ and last column of a row carry it as an outer, border-box, inlined padding;
16
+ nested columns do not repeat it.
17
+ - `<title>` in the layout head, set to the message subject (new
18
+ `layouts/activemail/_document_head` partial).
19
+ - Quality rules, on by default and disableable: `title`, `anchor_href`,
20
+ `placeholder` (`BuiltinRules::PLACEHOLDER`), `unsupported_color`,
21
+ `text_entities`.
22
+ - `Quality::Configuration#allow_declined` and the `allow_declined:` option of
23
+ `Guard#violations`, `Guard#valid?`, `assert_email_quality` and
24
+ `be_a_valid_email`.
25
+ - `Quality::Configuration#rule_exemptions` (by preview name or `preview#email`) and
26
+ the `skip:` option of `Guard#violations`, `Guard#valid?`, `assert_email_quality`
27
+ and `be_a_valid_email`: exempt a preview from named rules, built-in or custom,
28
+ without disabling them for every preview.
29
+ - `mso-padding-alt` on the button cell so Outlook keeps the padding.
30
+ - Below the container width, stacked columns take the container gutter on both
31
+ sides in clients that keep media queries.
32
+
33
+ ### Changed
34
+
35
+ - A nested `<columns>` ghost cell and `max-width` are computed against the
36
+ content box of the enclosing columns (their share minus their padding)
37
+ instead of the container.
38
+ - The MSO ghost cell of a column wraps it in its own `<table><tr>` (the `<th>`
39
+ used to sit directly in a `<td>`).
40
+ - Conditional comments use `<!--[if mso]>` instead of `<!--[if mso | IE]>`.
41
+ - `<button>` and `<cta>`: the fill and the radius live on the cell only; the
42
+ `<a>` carries `display:inline-block`, the body font and a `1.25` line height.
43
+ The dark-mode fill targets the cell.
44
+ - `<center>` renders a `<center align="center" class="float-center">` and no
45
+ longer rewrites its children.
46
+ - A mailer that declines to send (`NullMail`) is a `declined` violation unless
47
+ allowed.
48
+ - The generated layouts render the same `_document_head`, `_head` and `_footer`
49
+ partials as the gem layout.
50
+ - With Premailer the parsed framework CSS is cached per stylesheet fingerprint
51
+ (a FIFO of 16) instead of being re-parsed for every email.
52
+
53
+ ### Removed
54
+
55
+ - The `.columns.first.last` SCSS rule: a lone column now takes both container
56
+ gutters through `.first` and `.last`.
57
+
58
+ ## [2.0.0] - 2026-10-16
59
+
60
+ Breaking release: see [UPGRADING.md](UPGRADING.md).
61
+
62
+ ### Fixed
63
+
64
+ - Emails rendered by a real Rails app shipped without the framework CSS: the
65
+ stylesheet lookup probed `config.assets` (an `OrderedOptions` that answers
66
+ every `respond_to?` with `nil`) and the `<link>` fallback was stripped by
67
+ Premailer. Assets are now read from the actual Sprockets environment/manifest
68
+ or Propshaft load path.
69
+ - Propshaft hosts no longer risk a boot crash on `assets.precompile`.
70
+ - `<raw>` content is no longer transformed when the layout re-rendered the view,
71
+ and an author's literal `###RAW0###` is left intact.
72
+ - The word "doctype" in content and `&nbsp;`-like characters inside `<style>`
73
+ are no longer rewritten.
74
+ - Numeric attributes are parsed in strict base 10 (`"010"` was read as 8).
75
+ - `<cta>`, `<info-box>`, `<spacer>` and `<block-grid>` keep `id`, `aria-*`,
76
+ `data-*` and the author's `style`.
77
+ - Column widths only count sibling columns (a sibling `<p>` skewed them).
78
+ - The secondary button and info-box tokens now reach the stylesheet and dark
79
+ mode (1.1.0 announced it, but the SCSS ignored them).
80
+ - `.h-line`, `.block-grid.up-N`, `.menu`, `.expander` had no CSS.
81
+ - `render_all` refuses an `output_dir` outside the app and only deletes its own
82
+ files (it used to `rm_rf` the whole directory).
83
+ - The responsive breakpoint follows `container_width` instead of a fixed 596px.
84
+ - Code annotations are registered for the real `inky-*` extensions.
85
+
86
+ ### Changed
87
+
88
+ - One transpilation and one inlining pass per email, hooked on
89
+ `ActionMailer::Base#render_to_body`; text parts, attachments and
90
+ non-ActiveMail emails are untouched.
91
+ - `activemail_inline_styles(*paths)` embeds host stylesheets too and raises
92
+ `ActiveMail::StylesheetNotFound` instead of warning.
93
+ - `<button>` and `<cta>` share one bulletproof, token-styled, inlined rendering.
94
+ - SCSS uses the module system (`@use`/`@forward`, `sass:color`, `sass:math`).
95
+ - Transpilation is linear in the document size (200 nested rows: ×3.7,
96
+ 2,000 raw blocks: ×35) and reuses a memoized `Core`.
97
+ - `Quality::Guard` is extensible with `Quality::Rule` and gains
98
+ `translation_missing`, `single_root`, `text_part_present`, `text_part_plain`.
99
+ - `Quality` gains `locales`, `formats`, `around_each` and `output_filters`.
100
+ - Errors share the `ActiveMail::Error` base class.
101
+
102
+ ### Removed
103
+
104
+ - `ActiveMail::Inliner::Interceptor` and `config.register_inline_interceptor`.
105
+ - `ActiveMail::CompiledStylesheet` (replaced by `ActiveMail::AssetPipeline`).
106
+ - The `<inky>` tag.
107
+ - `IGNORED_ON_PASSTHROUGH`, `combine_attributes`, `pass_through_attributes`,
108
+ `bulletproof_button_table` and `BUTTON_PADDING`.
109
+ - `stylesheet_link_tag` from the layouts.
110
+ - The manual `workflow_dispatch` release trigger.
111
+
8
112
  ## [1.2.3] - 2026-10-09
9
113
 
10
114
  ### Changed
@@ -61,8 +165,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
61
165
  - `<button>` and `<cta>` share a `bulletproof_button_table` scaffold; `<cta>`
62
166
  drops its hardcoded `4px` radius in favor of the `radius` token.
63
167
  - The corner radius is now applied by default on `.button`/`.cta` (the opt-in
64
- `.radius` class is gone); `.button.secondary`/`.cta.secondary` mirror the
65
- secondary color/border tokens.
168
+ `.radius` class is gone).
66
169
 
67
170
  ### Removed
68
171
 
@@ -138,5 +241,7 @@ toolkit for Rails.
138
241
  - Rails `>= 7.1` (tested up to 8.1).
139
242
  - Nokogiri `>= 1.16`.
140
243
 
244
+ [2.1.0]: https://github.com/AdVitam/activemail/compare/v2.0.0...v2.1.0
245
+ [2.0.0]: https://github.com/AdVitam/activemail/compare/v1.2.3...v2.0.0
141
246
  [1.0.1]: https://github.com/AdVitam/activemail/compare/v1.0.0...v1.0.1
142
247
  [1.0.0]: https://github.com/AdVitam/activemail/releases/tag/v1.0.0
data/README.md CHANGED
@@ -35,14 +35,14 @@ renders consistently from Apple Mail to Outlook (Word engine) to Gmail mobile.
35
35
  - **Automatic CSS inlining** via a pluggable adapter (premailer by default, roadie
36
36
  optional, or your own).
37
37
  - **Generators** to install, eject views/styles, and scaffold components.
38
- - **Opt-in quality layer**: a Guard (size, `role`, `alt`, `lang`), preview
38
+ - **Opt-in quality layer**: a Guard (size, `role`, `alt`, `lang`, `title`, links, placeholders), preview
39
39
  renderer, Minitest assertions + RSpec matcher, and a `render_all` rake task.
40
40
 
41
41
  ## Installation
42
42
 
43
43
  ```ruby
44
44
  # Gemfile
45
- gem 'activemail', '~> 1.0'
45
+ gem 'activemail', '~> 2.1'
46
46
  ```
47
47
 
48
48
  ```bash
@@ -55,7 +55,8 @@ framework stylesheet. It works zero-config; customize only what you want.
55
55
 
56
56
  Name a mailer view `welcome.html.inky` (or compose with another engine:
57
57
  `welcome.html.inky-erb`, `.inky-slim`, `.inky-haml`) and it is transpiled on
58
- render. CSS is inlined automatically before delivery.
58
+ render. Each email is transpiled and its CSS inlined exactly once, when the
59
+ mailer renders it (view and layout together).
59
60
 
60
61
  ## Configuration
61
62
 
@@ -70,15 +71,12 @@ ActiveMail.configure do |config|
70
71
  # CSS inliner: :premailer (default), :roadie, :null, or a custom
71
72
  # ActiveMail::Inliner::Base subclass/instance.
72
73
  config.inliner = :premailer
73
- # Set false if another inliner (e.g. premailer-rails) already runs on mailers;
74
- # `config.inliner = :null` also fully short-circuits ActiveMail's interceptor.
75
- config.register_inline_interceptor = true
76
74
 
77
75
  # Design tokens (see below).
78
76
  config.tokens.color :primary, '#2a9d8f'
79
77
 
80
78
  # Custom components (see "Custom components").
81
- config.register_component 'cta', ActiveMail::Components::Cta
79
+ config.register_component 'divider', Divider
82
80
  end
83
81
  ```
84
82
 
@@ -106,7 +104,11 @@ ActiveMail.tokens.color(:primary) # => "#2a9d8f"
106
104
  ```
107
105
 
108
106
  Defaults are neutral (a calm teal `primary`, near-black `text`, white
109
- `background`, …) and fully overridable. Under Sprockets the SCSS bridge is a
107
+ `background`, …) and fully overridable. Optional tokens fall back to a base
108
+ token: `primary_text`/`secondary_text` → `button_text`, `primary_border`/
109
+ `secondary_border` → none, `info_box_background`/`_border`/`_text` →
110
+ `background`/`border`/`text`. Ruby components and the stylesheet read the same
111
+ resolved values. Under Sprockets the SCSS bridge is a
110
112
  preprocessed partial; under Propshaft run `rake activemail:tokens:export` to
111
113
  materialize a static `_activemail_tokens.scss`.
112
114
 
@@ -118,13 +120,25 @@ hooks (`.button`, `.cta`, `.callout`, `.spacer`, …), utilities, and dark mode.
118
120
 
119
121
  Override at three levels, cheapest first:
120
122
 
121
- 1. **Tokens** (Ruby) — covers most theming.
123
+ 1. **Tokens** (Ruby) — covers most theming. Any framework variable can also be
124
+ configured from your own stylesheet with the Sass module system:
125
+
126
+ ```scss
127
+ // app/assets/stylesheets/emails/emails.scss
128
+ @use "activemail/activemail" with (
129
+ $am-button-secondary-color: #264653,
130
+ $am-breakpoint: 640px
131
+ );
132
+ ```
133
+
122
134
  2. **`bin/rails g activemail:styles`** — eject the SCSS partials into your app to
123
135
  edit them; your copies shadow the gem's.
124
136
  3. **`bin/rails g activemail:views`** — eject the default layout + partials
125
137
  (`app/views/layouts/activemail/*`); a same-named file in your app wins by
126
138
  Rails path precedence. Put your logo/header/footer here — those are the app's
127
- identity, not the gem's.
139
+ identity, not the gem's. The `<head>` lives in `_document_head` (metas,
140
+ `<title>` set to the message subject, Outlook settings, inlined styles) and
141
+ is shared by the default layout and the generated one.
128
142
 
129
143
  Dark mode ships on: a `<style>` block keys off `prefers-color-scheme: dark` (Apple
130
144
  Mail/iOS) and Outlook's `[data-ogsc]`, with surfaces derived from your tokens.
@@ -132,9 +146,24 @@ Mail/iOS) and Outlook's `[data-ogsc]`, with surfaces derived from your tokens.
132
146
  ## CSS inlining
133
147
 
134
148
  Email clients (Gmail mobile, Orange.fr) strip `<style>`, so critical CSS must be
135
- inlined. ActiveMail registers an ActionMailer interceptor that runs the configured
136
- inliner on every outgoing HTML part — your `<style>` enhancement block (media
137
- queries, dark mode) is preserved.
149
+ inlined. When a mailer renders an inky HTML template, ActiveMail transpiles the
150
+ whole body (view + layout) and runs the configured inliner once. Text parts,
151
+ attachments and non-ActiveMail emails are never touched; your `<style>`
152
+ enhancement block (media queries, dark mode) is preserved. With Premailer the
153
+ parsed framework CSS is cached per stylesheet, so it is parsed once, not once per
154
+ email.
155
+
156
+ The layout embeds the compiled stylesheets with `activemail_inline_styles`
157
+ (Sprockets or Propshaft); pass your own on top of the framework's:
158
+
159
+ ```erb
160
+ <%= activemail_inline_styles %> <%# framework only %>
161
+ <%= activemail_inline_styles "emails/emails" %> <%# your stylesheet @use-ing the framework %>
162
+ ```
163
+
164
+ A missing or empty asset raises `ActiveMail::StylesheetNotFound`: an email is
165
+ never sent unstyled. An inliner failure raises `ActiveMail::Inliner::Error`
166
+ (the adapter's exception is kept as `#cause`).
138
167
 
139
168
  ```ruby
140
169
  config.inliner = :premailer # default, hard dependency
@@ -157,11 +186,11 @@ Fluid-hybrid wrapper, capped at `container_width`, wrapped in an MSO ghost table
157
186
  ```
158
187
 
159
188
  ```html
160
- <!--[if mso | IE]><table role="presentation" align="center" width="600">...<![endif]-->
189
+ <!--[if mso]><table role="presentation" align="center" width="600">...<![endif]-->
161
190
  <table class="container" align="center" style="width:100%;max-width:600px;margin:0 auto;">
162
191
  <tbody><tr><td>...</td></tr></tbody>
163
192
  </table>
164
- <!--[if mso | IE]></td></tr></table><![endif]-->
193
+ <!--[if mso]></td></tr></table><![endif]-->
165
194
  ```
166
195
 
167
196
  ### `<row>` / `<columns>`
@@ -176,21 +205,39 @@ media-query enhancement.
176
205
  ```
177
206
 
178
207
  Column widths are computed as `container_width × large / column_count`, capped
179
- at `container_width`, **with no gutter model**: two `large="6"` columns sit
180
- edge-to-edge (300px + 300px in a 600px container). Add padding inside your
181
- columns for gutters, and keep the `large` sizes of a row summing to at most
182
- `column_count`, otherwise the ghost cells will wrap in Outlook.
208
+ at `container_width`. A nested `<columns>` is sized against its enclosing column
209
+ (a `large="6"` inside a `large="6"` is 150px in a 600px container). Keep the
210
+ `large` sizes of a row summing to at most `column_count`, otherwise the ghost
211
+ cells will wrap in Outlook.
212
+
213
+ Gutters are border-box padding inlined on the columns: `gutter` (8px) on each
214
+ side between two columns, and `container_gutter` (16px) on the outer edge of the
215
+ first and last column, so content never touches the screen edge. A nested row
216
+ adds no outer gutter. Both are spacing tokens
217
+ (`config.tokens.spacing :container_gutter, '0px'` restores edge-to-edge columns)
218
+ and SCSS variables (`$am-gutter`, `$am-container-gutter`).
183
219
 
184
220
  ### `<button>`
185
221
 
186
- Bulletproof button: the padding lives on the `<a>` so the whole control is
187
- clickable. Variant classes (`primary`, `expand`, …) are preserved.
222
+ Bulletproof button styled inline from the tokens (`Tokens#button_style`): the
223
+ padding lives on the `<a>` so the whole control is clickable (mirrored on the
224
+ cell with `mso-padding-alt` for Outlook), the fill and radius sit on the cell
225
+ only, and a variant border sits on the cell as well. `class="secondary"` selects the secondary variant;
226
+ `expand` makes it full width; without `href` the label renders as a `<span>`.
188
227
 
189
228
  ```html
190
- <button href="#">Go</button>
229
+ <button href="#" class="secondary">Go</button>
191
230
  ```
192
231
 
193
- `<button class="expand">` adds a centered link and an `.expander` cell.
232
+ ```html
233
+ <table class="secondary button"><tbody><tr><td>
234
+ <table><tbody><tr>
235
+ <td style="background:#264653;border-radius:4px;mso-padding-alt:12px 24px;">
236
+ <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>
237
+ </td>
238
+ </tr></tbody></table>
239
+ </td></tr></tbody></table>
240
+ ```
194
241
 
195
242
  ### `<menu>` / `<item>`
196
243
 
@@ -222,17 +269,13 @@ clickable. Variant classes (`primary`, `expand`, …) are preserved.
222
269
  ### `<wrapper>`, `<h-line>`, `<center>`
223
270
 
224
271
  - `<wrapper class="header">` → `<table class="header wrapper" align="center" style="width:100%;">` with a `.wrapper-inner` cell.
225
- - `<h-line>` → a full-width single-cell table for a horizontal rule.
226
- - `<center>` adds `align="center"` and `.float-center` to its element children (and `.float-center` to nested menu items).
227
-
228
- ### `<inky>`
229
-
230
- Renders a bare `<tr>`, useful inside hand-written tables.
272
+ - `<h-line>` → a full-width single-cell table drawn as a 1px rule in the `border` color.
273
+ - `<center>` renders a `<center align="center" class="float-center">` around its content and leaves the content untouched.
231
274
 
232
275
  ### `<raw>`
233
276
 
234
277
  Anything between `<raw>` and `</raw>` is passed through untouched (multi-line
235
- supported). Raw blocks cannot be nested.
278
+ supported), including through the layout. Raw blocks cannot be nested.
236
279
 
237
280
  ```html
238
281
  <raw><% liquid_or_mso_conditional %></raw>
@@ -240,28 +283,28 @@ supported). Raw blocks cannot be nested.
240
283
 
241
284
  ### Token-driven built-ins: `<cta>` and `<info-box>`
242
285
 
243
- Brand-neutral, token-styled components shipped with the gem but **not registered
244
- by default** (so they never collide with your tags). Register them to use:
245
-
246
- ```ruby
247
- config.register_component 'cta', ActiveMail::Components::Cta
248
- config.register_component 'info-box', ActiveMail::Components::InfoBox
249
- ```
286
+ Brand-neutral, token-styled components, registered by default.
250
287
 
251
288
  ```html
252
289
  <cta href="https://example.com">Go</cta>
253
290
  <cta href="#" class="secondary">Also go</cta>
254
291
  ```
255
292
 
256
- `<cta>` renders a bulletproof button using `tokens.color(:primary)` (or
257
- `:secondary` with `class="secondary"`); it raises if `href` is missing.
293
+ `<cta>` renders exactly like `<button>` with a `cta` class; it raises if `href`
294
+ is missing. `<info-box>` is a callout styled from the `info_box_*` tokens.
295
+
296
+ Every component keeps the author's `class` and `style` (merged, never
297
+ duplicated) and passes `id`, `aria-*`, `data-*` and other attributes through to
298
+ its root element.
258
299
 
259
300
  ## Custom components
260
301
 
261
302
  Register your own tag with a class that inherits from
262
303
  `ActiveMail::Components::Base` and implements `#transform(node, inner)`. You get
263
- the matched Nokogiri node (full DOM access) and the already-transformed inner
264
- HTML; return the replacement markup string. The generator scaffolds one:
304
+ the matched Nokogiri node (read its attributes) and `inner`, an opaque slot
305
+ standing for the already-transformed children: interpolate it exactly once where
306
+ the children belong, never parse or alter it (dropping or duplicating it raises
307
+ `ActiveMail::Error`). Return the replacement markup string. The generator scaffolds one:
265
308
 
266
309
  ```bash
267
310
  bin/rails g activemail:component Divider
@@ -272,19 +315,21 @@ The generator namespaces the class as `Components::Divider` (under
272
315
  A minimal hand-written equivalent:
273
316
 
274
317
  ```ruby
275
- class Divider < ActiveMail::Components::Base
276
- def transform(node, _inner)
277
- klass = combine_classes(node, 'divider')
278
- %(<table class="#{klass}" #{TABLE_RESET} style="width:100%;"><tbody><tr><td></td></tr></tbody></table>)
318
+ class Divider < ActiveMail::Components::TableComponent
319
+ def transform(node, inner)
320
+ %(<table#{root_attributes(node, classes: 'divider', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
279
321
  end
280
322
  end
281
323
 
282
324
  ActiveMail.configuration.register_component('divider', Divider)
283
325
  ```
284
326
 
285
- Helpers available from `Base`: `combine_classes`, `combine_attributes`,
286
- `pass_through_attributes`, `class?`, `target_attribute`, `escape_attr`,
287
- `style_attribute`, `column_count`, `container_width`.
327
+ `root_attributes` emits the merged `class`, the merged `style` and every
328
+ pass-through attribute; override `consumed_attributes` to list the attributes
329
+ your component reads itself (they are not passed through). `TableComponent`
330
+ already consumes the `TABLE_RESET` attributes (`role`, `border`, …) of a layout
331
+ table root; inherit from `Base` for any other root element. Other helpers:
332
+ `class?`, `escape_attr`, `column_count`, `container_width`.
288
333
 
289
334
  Per-instance overrides (including replacing a built-in tag) are also possible:
290
335
 
@@ -314,9 +359,15 @@ class MailerTest < ActiveSupport::TestCase
314
359
  include ActiveMail::Quality::Minitest
315
360
 
316
361
  test 'welcome email is sound' do
317
- assert_email_quality(rendered_html)
362
+ assert_email_quality(WelcomeMailer.welcome(user)) # a message, or an HTML string
318
363
  end
319
364
  end
365
+
366
+ class EmailPreviewsTest < ActiveSupport::TestCase
367
+ include ActiveMail::Quality::Minitest
368
+
369
+ assert_quality_for_all_previews # one test per preview email and locale
370
+ end
320
371
  ```
321
372
 
322
373
  ```ruby
@@ -326,19 +377,52 @@ require 'activemail/quality/rspec'
326
377
  expect(rendered_html).to be_a_valid_email
327
378
  ```
328
379
 
329
- The Guard checks byte size (Gmail clips ~102 KB), `role="presentation"` on every
330
- table, `alt` on every image, and `lang` on full documents — all thresholds
331
- configurable:
380
+ The Guard checks byte size (Gmail clips ~102 KB), parse errors,
381
+ `role="presentation"` on every table, `alt` on every image, `lang` on full
382
+ documents, a single `<html>`/`<body>`, missing translations, a non-blank
383
+ `<title>`, an `href` on every link, unresolved `{{placeholders}}`, CSS colors
384
+ clients ignore (`rgb(… %)`, `hsl()`, `color-mix()`, `var()`), and — given a
385
+ message — a plain text part free of HTML entities. A mailer that declines to
386
+ send (`NullMail`) is a violation unless it is listed in `allow_declined`
387
+ (`assert_email_quality(mail, allow_declined: true)` or
388
+ `be_a_valid_email(allow_declined: true)` for one call). Built-in rules can be
389
+ disabled and your own added; a single email can also be exempt from a rule
390
+ (`assert_email_quality(html, skip: %i[img_alt])`, `be_a_valid_email(skip: …)`, or
391
+ `rule_exemptions` below) while every other rule still applies to it. A name the
392
+ guard does not know raises an `ArgumentError` before any preview renders; a
393
+ disabled rule is a known name.
332
394
 
333
395
  ```ruby
396
+ signature = ActiveMail::Quality::Rule.html(:single_signature) do |doc, _html|
397
+ 'signature rendered twice' if doc.css('.signature').size > 1
398
+ end
399
+
334
400
  ActiveMail::Quality.configure do |c|
335
401
  c.required_previews = %w[welcome_mailer#welcome]
336
- c.guard = ActiveMail::Quality::Guard.new(max_bytes: 90_000)
402
+ c.allow_declined = %w[reminder_mailer#maybe_skipped] # previews allowed to return a NullMail
403
+ c.rule_exemptions = { # a preview name covers all its emails
404
+ 'legacy_mailer' => %i[img_alt],
405
+ 'welcome_mailer#welcome' => %i[single_signature]
406
+ }
407
+ c.guard = ActiveMail::Quality::Guard.new(max_bytes: 90_000, disable: %i[img_alt], rules: [signature])
408
+ c.locales = %i[fr en] # or ->(preview) { [...] }; each preview gets params[:locale]
409
+ c.formats = %i[html text]
410
+ c.around_each = lambda do |_preview, _email, _locale, &render|
411
+ ActiveRecord::Base.transaction { render.call; raise ActiveRecord::Rollback } # previews leave no data behind
412
+ end
413
+ c.output_filters = [[/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/, '<uuid>']] # applied to written files only
414
+ c.output_dir = 'tmp/email_previews' # relative, inside Rails.root
337
415
  end
338
416
  ```
339
417
 
340
- `rake activemail:emails:render_all` renders every ActionMailer preview to disk
341
- for visual diffing and fails on any guard violation among `required_previews`.
418
+ `Rule.html` blocks receive the parsed document and the raw HTML; `Rule.message`
419
+ blocks receive the `Mail::Message`. Return `nil`, a message or an array of
420
+ messages.
421
+
422
+ `rake activemail:emails:render_all` renders every ActionMailer preview (each
423
+ locale and format) to `output_dir` for visual diffing and fails on any guard
424
+ violation among `required_previews`. It only replaces its own `.html`/`.txt`
425
+ files.
342
426
 
343
427
  ## Email-client compatibility policy
344
428
 
@@ -356,7 +440,8 @@ Targets the real-world client landscape as of 2026:
356
440
  ## Programmatic use
357
441
 
358
442
  ```ruby
359
- ActiveMail::Core.new.transpile('<container><row><columns>Hi</columns></row></container>')
443
+ ActiveMail.configuration.core.transpile('<container><row><columns>Hi</columns></row></container>')
444
+ ActiveMail::Pipeline.call(source) # transpile + inline
360
445
  ActiveMail::Core.new(column_count: 24, container_width: 480).transpile(source)
361
446
  ```
362
447