activemail 1.2.3 → 2.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -2
  3. data/README.md +105 -47
  4. data/UPGRADING.md +112 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +31 -20
  7. data/app/assets/stylesheets/activemail/_grid.scss +2 -0
  8. data/app/assets/stylesheets/activemail/_settings.scss +22 -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/mailer.html.inky-erb +0 -5
  13. data/lib/activemail/asset_pipeline/base.rb +47 -0
  14. data/lib/activemail/asset_pipeline/null.rb +19 -0
  15. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  16. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  17. data/lib/activemail/asset_pipeline.rb +35 -0
  18. data/lib/activemail/components/base.rb +35 -39
  19. data/lib/activemail/components/block_grid.rb +11 -4
  20. data/lib/activemail/components/button.rb +5 -28
  21. data/lib/activemail/components/button_renderer.rb +65 -0
  22. data/lib/activemail/components/callout.rb +4 -5
  23. data/lib/activemail/components/center.rb +5 -7
  24. data/lib/activemail/components/columns.rb +26 -7
  25. data/lib/activemail/components/container.rb +11 -4
  26. data/lib/activemail/components/cta.rb +9 -33
  27. data/lib/activemail/components/h_line.rb +3 -5
  28. data/lib/activemail/components/info_box.rb +6 -12
  29. data/lib/activemail/components/menu.rb +3 -4
  30. data/lib/activemail/components/menu_item.rb +10 -3
  31. data/lib/activemail/components/row.rb +3 -4
  32. data/lib/activemail/components/spacer.rb +16 -12
  33. data/lib/activemail/components/table_component.rb +23 -0
  34. data/lib/activemail/components/wrapper.rb +10 -4
  35. data/lib/activemail/configuration.rb +43 -57
  36. data/lib/activemail/errors.rb +15 -0
  37. data/lib/activemail/inliner/base.rb +2 -10
  38. data/lib/activemail/inliner/null.rb +0 -5
  39. data/lib/activemail/inliner/roadie.rb +1 -1
  40. data/lib/activemail/inliner.rb +33 -0
  41. data/lib/activemail/parse_error_reporter.rb +5 -4
  42. data/lib/activemail/pipeline.rb +26 -0
  43. data/lib/activemail/quality/builtin_rules.rb +154 -0
  44. data/lib/activemail/quality/configuration.rb +83 -3
  45. data/lib/activemail/quality/guard.rb +45 -87
  46. data/lib/activemail/quality/minitest.rb +41 -25
  47. data/lib/activemail/quality/output_dir.rb +52 -0
  48. data/lib/activemail/quality/preview_renderer.rb +46 -33
  49. data/lib/activemail/quality/render_all.rb +96 -40
  50. data/lib/activemail/quality/rspec.rb +4 -8
  51. data/lib/activemail/quality/rule.rb +47 -0
  52. data/lib/activemail/rails/engine.rb +13 -13
  53. data/lib/activemail/rails/rendering.rb +51 -0
  54. data/lib/activemail/rails/template_handler.rb +3 -1
  55. data/lib/activemail/tokens/button_style.rb +1 -2
  56. data/lib/activemail/tokens/defaults.rb +77 -0
  57. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  58. data/lib/activemail/tokens.rb +19 -58
  59. data/lib/activemail/version.rb +1 -1
  60. data/lib/activemail.rb +89 -61
  61. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  62. data/lib/generators/activemail/templates/initializer.rb +0 -3
  63. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +0 -2
  64. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
  65. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
  66. data/lib/tasks/activemail.rake +4 -5
  67. metadata +17 -4
  68. data/lib/activemail/components/inky.rb +0 -18
  69. data/lib/activemail/inliner/interceptor.rb +0 -51
  70. 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: c37e5bd4d1c2746462f33f9b221a2734081ec4f89be697f77e22f8402767c879
4
+ data.tar.gz: 69b5e178c7521b8f741a504af3c4d5524373b68b80cbe44af35a38a168e77389
5
5
  SHA512:
6
- metadata.gz: 28ace5f87d6884920b5f99072306d693b7fcb61b7ee0c1162b8d498c33a67a5b54f63ef1e61277d92523ebc1433fc281254d959ed88e914c2ccc60157c19ac25
7
- data.tar.gz: 67e448df315fae10c0faa563da2e0c7c5da4af21f03e0e801ed8018f44e981dcd9e1a881dd3038dd637a4a30a40ce40973ad178dd181ff4b6358d6d6438b5635
6
+ metadata.gz: 2611a2f9405fcdb5e41de1f5817b603d3574ccbc5a0607cd45b22ec85f34cf283d3bd8bfdb22786b48e8248b6b941935a126466fb83a5e70c25ce144be41b64b
7
+ data.tar.gz: 7d398eb0eaa80095458a9eb3811aa906f85d212abb6c784166edceeb3249c91f33d3e011efe55b9f4be3fd6e24aadf0246539f0be5f29dc6e9ca28a297475bd9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ 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.0.0] - Unreleased
9
+
10
+ Breaking release: see [UPGRADING.md](UPGRADING.md).
11
+
12
+ ### Fixed
13
+
14
+ - Emails rendered by a real Rails app shipped without the framework CSS: the
15
+ stylesheet lookup probed `config.assets` (an `OrderedOptions` that answers
16
+ every `respond_to?` with `nil`) and the `<link>` fallback was stripped by
17
+ Premailer. Assets are now read from the actual Sprockets environment/manifest
18
+ or Propshaft load path.
19
+ - Propshaft hosts no longer risk a boot crash on `assets.precompile`.
20
+ - `<raw>` content is no longer transformed when the layout re-rendered the view,
21
+ and an author's literal `###RAW0###` is left intact.
22
+ - The word "doctype" in content and `&nbsp;`-like characters inside `<style>`
23
+ are no longer rewritten.
24
+ - Numeric attributes are parsed in strict base 10 (`"010"` was read as 8).
25
+ - `<cta>`, `<info-box>`, `<spacer>` and `<block-grid>` keep `id`, `aria-*`,
26
+ `data-*` and the author's `style`.
27
+ - Column widths only count sibling columns (a sibling `<p>` skewed them).
28
+ - The secondary button and info-box tokens now reach the stylesheet and dark
29
+ mode (1.1.0 announced it, but the SCSS ignored them).
30
+ - `.h-line`, `.block-grid.up-N`, `.menu`, `.expander` had no CSS.
31
+ - `render_all` refuses an `output_dir` outside the app and only deletes its own
32
+ files (it used to `rm_rf` the whole directory).
33
+ - The responsive breakpoint follows `container_width` instead of a fixed 596px.
34
+ - Code annotations are registered for the real `inky-*` extensions.
35
+
36
+ ### Changed
37
+
38
+ - One transpilation and one inlining pass per email, hooked on
39
+ `ActionMailer::Base#render_to_body`; text parts, attachments and
40
+ non-ActiveMail emails are untouched.
41
+ - `activemail_inline_styles(*paths)` embeds host stylesheets too and raises
42
+ `ActiveMail::StylesheetNotFound` instead of warning.
43
+ - `<button>` and `<cta>` share one bulletproof, token-styled, inlined rendering.
44
+ - SCSS uses the module system (`@use`/`@forward`, `sass:color`, `sass:math`).
45
+ - Transpilation is linear in the document size (200 nested rows: ×3.7,
46
+ 2,000 raw blocks: ×35) and reuses a memoized `Core`.
47
+ - `Quality::Guard` is extensible with `Quality::Rule` and gains
48
+ `translation_missing`, `single_root`, `text_part_present`, `text_part_plain`.
49
+ - `Quality` gains `locales`, `formats`, `around_each` and `output_filters`.
50
+ - Errors share the `ActiveMail::Error` base class.
51
+
52
+ ### Removed
53
+
54
+ - `ActiveMail::Inliner::Interceptor` and `config.register_inline_interceptor`.
55
+ - `ActiveMail::CompiledStylesheet` (replaced by `ActiveMail::AssetPipeline`).
56
+ - The `<inky>` tag.
57
+ - `IGNORED_ON_PASSTHROUGH`, `combine_attributes`, `pass_through_attributes`,
58
+ `bulletproof_button_table` and `BUTTON_PADDING`.
59
+ - `stylesheet_link_tag` from the layouts.
60
+ - The manual `workflow_dispatch` release trigger.
61
+
8
62
  ## [1.2.3] - 2026-10-09
9
63
 
10
64
  ### Changed
@@ -61,8 +115,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
61
115
  - `<button>` and `<cta>` share a `bulletproof_button_table` scaffold; `<cta>`
62
116
  drops its hardcoded `4px` radius in favor of the `radius` token.
63
117
  - 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.
118
+ `.radius` class is gone).
66
119
 
67
120
  ### Removed
68
121
 
data/README.md CHANGED
@@ -42,7 +42,7 @@ renders consistently from Apple Mail to Outlook (Word engine) to Gmail mobile.
42
42
 
43
43
  ```ruby
44
44
  # Gemfile
45
- gem 'activemail', '~> 1.0'
45
+ gem 'activemail', '~> 2.0'
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,7 +120,17 @@ 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
@@ -132,9 +144,22 @@ Mail/iOS) and Outlook's `[data-ogsc]`, with surfaces derived from your tokens.
132
144
  ## CSS inlining
133
145
 
134
146
  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.
147
+ inlined. When a mailer renders an inky HTML template, ActiveMail transpiles the
148
+ whole body (view + layout) and runs the configured inliner once. Text parts,
149
+ attachments and non-ActiveMail emails are never touched; your `<style>`
150
+ enhancement block (media queries, dark mode) is preserved.
151
+
152
+ The layout embeds the compiled stylesheets with `activemail_inline_styles`
153
+ (Sprockets or Propshaft); pass your own on top of the framework's:
154
+
155
+ ```erb
156
+ <%= activemail_inline_styles %> <%# framework only %>
157
+ <%= activemail_inline_styles "emails/emails" %> <%# your stylesheet @use-ing the framework %>
158
+ ```
159
+
160
+ A missing or empty asset raises `ActiveMail::StylesheetNotFound`: an email is
161
+ never sent unstyled. An inliner failure raises `ActiveMail::Inliner::Error`
162
+ (the adapter's exception is kept as `#cause`).
138
163
 
139
164
  ```ruby
140
165
  config.inliner = :premailer # default, hard dependency
@@ -183,14 +208,24 @@ columns for gutters, and keep the `large` sizes of a row summing to at most
183
208
 
184
209
  ### `<button>`
185
210
 
186
- Bulletproof button: the padding lives on the `<a>` so the whole control is
187
- clickable. Variant classes (`primary`, `expand`, …) are preserved.
211
+ Bulletproof button styled inline from the tokens (`Tokens#button_style`): the
212
+ padding lives on the `<a>` so the whole control is clickable, and a variant
213
+ border sits on the cell only. `class="secondary"` selects the secondary variant;
214
+ `expand` makes it full width; without `href` the label renders as a `<span>`.
188
215
 
189
216
  ```html
190
- <button href="#">Go</button>
217
+ <button href="#" class="secondary">Go</button>
191
218
  ```
192
219
 
193
- `<button class="expand">` adds a centered link and an `.expander` cell.
220
+ ```html
221
+ <table class="secondary button"><tbody><tr><td>
222
+ <table><tbody><tr>
223
+ <td style="background:#264653;border-radius:4px;">
224
+ <a href="#" style="display:inline-block;text-decoration:none;padding:12px 24px;background:#264653;color:#ffffff;font-weight:bold;border-radius:4px;">Go</a>
225
+ </td>
226
+ </tr></tbody></table>
227
+ </td></tr></tbody></table>
228
+ ```
194
229
 
195
230
  ### `<menu>` / `<item>`
196
231
 
@@ -222,17 +257,13 @@ clickable. Variant classes (`primary`, `expand`, …) are preserved.
222
257
  ### `<wrapper>`, `<h-line>`, `<center>`
223
258
 
224
259
  - `<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.
260
+ - `<h-line>` → a full-width single-cell table drawn as a 1px rule in the `border` color.
226
261
  - `<center>` adds `align="center"` and `.float-center` to its element children (and `.float-center` to nested menu items).
227
262
 
228
- ### `<inky>`
229
-
230
- Renders a bare `<tr>`, useful inside hand-written tables.
231
-
232
263
  ### `<raw>`
233
264
 
234
265
  Anything between `<raw>` and `</raw>` is passed through untouched (multi-line
235
- supported). Raw blocks cannot be nested.
266
+ supported), including through the layout. Raw blocks cannot be nested.
236
267
 
237
268
  ```html
238
269
  <raw><% liquid_or_mso_conditional %></raw>
@@ -240,28 +271,28 @@ supported). Raw blocks cannot be nested.
240
271
 
241
272
  ### Token-driven built-ins: `<cta>` and `<info-box>`
242
273
 
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
- ```
274
+ Brand-neutral, token-styled components, registered by default.
250
275
 
251
276
  ```html
252
277
  <cta href="https://example.com">Go</cta>
253
278
  <cta href="#" class="secondary">Also go</cta>
254
279
  ```
255
280
 
256
- `<cta>` renders a bulletproof button using `tokens.color(:primary)` (or
257
- `:secondary` with `class="secondary"`); it raises if `href` is missing.
281
+ `<cta>` renders exactly like `<button>` with a `cta` class; it raises if `href`
282
+ is missing. `<info-box>` is a callout styled from the `info_box_*` tokens.
283
+
284
+ Every component keeps the author's `class` and `style` (merged, never
285
+ duplicated) and passes `id`, `aria-*`, `data-*` and other attributes through to
286
+ its root element.
258
287
 
259
288
  ## Custom components
260
289
 
261
290
  Register your own tag with a class that inherits from
262
291
  `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:
292
+ the matched Nokogiri node (read its attributes) and `inner`, an opaque slot
293
+ standing for the already-transformed children: interpolate it exactly once where
294
+ the children belong, never parse or alter it (dropping or duplicating it raises
295
+ `ActiveMail::Error`). Return the replacement markup string. The generator scaffolds one:
265
296
 
266
297
  ```bash
267
298
  bin/rails g activemail:component Divider
@@ -272,19 +303,21 @@ The generator namespaces the class as `Components::Divider` (under
272
303
  A minimal hand-written equivalent:
273
304
 
274
305
  ```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>)
306
+ class Divider < ActiveMail::Components::TableComponent
307
+ def transform(node, inner)
308
+ %(<table#{root_attributes(node, classes: 'divider', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
279
309
  end
280
310
  end
281
311
 
282
312
  ActiveMail.configuration.register_component('divider', Divider)
283
313
  ```
284
314
 
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`.
315
+ `root_attributes` emits the merged `class`, the merged `style` and every
316
+ pass-through attribute; override `consumed_attributes` to list the attributes
317
+ your component reads itself (they are not passed through). `TableComponent`
318
+ already consumes the `TABLE_RESET` attributes (`role`, `border`, …) of a layout
319
+ table root; inherit from `Base` for any other root element. Other helpers:
320
+ `class?`, `escape_attr`, `column_count`, `container_width`.
288
321
 
289
322
  Per-instance overrides (including replacing a built-in tag) are also possible:
290
323
 
@@ -314,9 +347,15 @@ class MailerTest < ActiveSupport::TestCase
314
347
  include ActiveMail::Quality::Minitest
315
348
 
316
349
  test 'welcome email is sound' do
317
- assert_email_quality(rendered_html)
350
+ assert_email_quality(WelcomeMailer.welcome(user)) # a message, or an HTML string
318
351
  end
319
352
  end
353
+
354
+ class EmailPreviewsTest < ActiveSupport::TestCase
355
+ include ActiveMail::Quality::Minitest
356
+
357
+ assert_quality_for_all_previews # one test per preview email and locale
358
+ end
320
359
  ```
321
360
 
322
361
  ```ruby
@@ -326,19 +365,37 @@ require 'activemail/quality/rspec'
326
365
  expect(rendered_html).to be_a_valid_email
327
366
  ```
328
367
 
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:
368
+ The Guard checks byte size (Gmail clips ~102 KB), parse errors,
369
+ `role="presentation"` on every table, `alt` on every image, `lang` on full
370
+ documents, a single `<html>`/`<body>`, missing translations, and — given a
371
+ message — a plain text part. Built-in rules can be disabled and your own added:
332
372
 
333
373
  ```ruby
374
+ signature = ActiveMail::Quality::Rule.html(:single_signature) do |doc, _html|
375
+ 'signature rendered twice' if doc.css('.signature').size > 1
376
+ end
377
+
334
378
  ActiveMail::Quality.configure do |c|
335
379
  c.required_previews = %w[welcome_mailer#welcome]
336
- c.guard = ActiveMail::Quality::Guard.new(max_bytes: 90_000)
380
+ c.guard = ActiveMail::Quality::Guard.new(max_bytes: 90_000, disable: %i[img_alt], rules: [signature])
381
+ c.locales = %i[fr en] # or ->(preview) { [...] }; each preview gets params[:locale]
382
+ c.formats = %i[html text]
383
+ c.around_each = lambda do |_preview, _email, _locale, &render|
384
+ ActiveRecord::Base.transaction { render.call; raise ActiveRecord::Rollback } # previews leave no data behind
385
+ end
386
+ c.output_filters = [[/\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/, '<uuid>']] # applied to written files only
387
+ c.output_dir = 'tmp/email_previews' # relative, inside Rails.root
337
388
  end
338
389
  ```
339
390
 
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`.
391
+ `Rule.html` blocks receive the parsed document and the raw HTML; `Rule.message`
392
+ blocks receive the `Mail::Message`. Return `nil`, a message or an array of
393
+ messages.
394
+
395
+ `rake activemail:emails:render_all` renders every ActionMailer preview (each
396
+ locale and format) to `output_dir` for visual diffing and fails on any guard
397
+ violation among `required_previews`. It only replaces its own `.html`/`.txt`
398
+ files.
342
399
 
343
400
  ## Email-client compatibility policy
344
401
 
@@ -356,7 +413,8 @@ Targets the real-world client landscape as of 2026:
356
413
  ## Programmatic use
357
414
 
358
415
  ```ruby
359
- ActiveMail::Core.new.transpile('<container><row><columns>Hi</columns></row></container>')
416
+ ActiveMail.configuration.core.transpile('<container><row><columns>Hi</columns></row></container>')
417
+ ActiveMail::Pipeline.call(source) # transpile + inline
360
418
  ActiveMail::Core.new(column_count: 24, container_width: 480).transpile(source)
361
419
  ```
362
420
 
data/UPGRADING.md ADDED
@@ -0,0 +1,112 @@
1
+ # Upgrading to ActiveMail 2.0
2
+
3
+ ## Gemfile
4
+
5
+ ```ruby
6
+ gem 'activemail', '~> 2.0'
7
+ ```
8
+
9
+ ## Inlining
10
+
11
+ The delivery interceptor is gone: each email is transpiled and inlined once,
12
+ when the mailer renders an inky HTML template.
13
+
14
+ - Remove `config.register_inline_interceptor` from your initializer.
15
+ - If you ran another inliner (premailer-rails) next to ActiveMail with
16
+ `config.inliner = :null`, drop that inliner and the `:null` setting: ActiveMail
17
+ now inlines the whole body, layout included.
18
+ - Inliner failures still raise `ActiveMail::Inliner::Error` (now a subclass of
19
+ `ActiveMail::Error`).
20
+
21
+ ## Layouts
22
+
23
+ Ejected or hand-written layouts must embed the stylesheets with the helper and
24
+ drop `stylesheet_link_tag` (Premailer strips it, so it never styled anything):
25
+
26
+ ```erb
27
+ <%# before %>
28
+ <%= stylesheet_link_tag "emails/emails" %>
29
+ <%= activemail_inline_styles %>
30
+
31
+ <%# after %>
32
+ <%= activemail_inline_styles "emails/emails" %>
33
+ ```
34
+
35
+ A missing or empty asset now raises `ActiveMail::StylesheetNotFound` instead of
36
+ logging a warning; inside a view it surfaces as the `#cause` of
37
+ `ActionView::Template::Error`.
38
+
39
+ ## SCSS
40
+
41
+ The framework uses the Sass module system. Replace `@import` and variables
42
+ declared before it with `@use … with (…)`; a variable declared before the
43
+ `@use` is no longer read.
44
+
45
+ ```scss
46
+ // before
47
+ $am-button-secondary-color: #264653;
48
+ @import "activemail/activemail";
49
+
50
+ // after
51
+ @use "activemail/activemail" with (
52
+ $am-button-secondary-color: #264653
53
+ );
54
+ ```
55
+
56
+ - Renamed: `$am-button-color` → `$am-button-primary-color`,
57
+ `$am-info-box-bg` → `$am-info-box-background`.
58
+ - Prefer Ruby tokens (`secondary_text`, `secondary_border`, `info_box_*`, …):
59
+ they now reach both the inline styles and the stylesheet, so SCSS overrides of
60
+ the button/info-box variables are rarely needed.
61
+ - `$am-breakpoint` defaults to the container width (600px) instead of 596px.
62
+
63
+ ## Buttons
64
+
65
+ `<button>` and `<cta>` share one rendering, styled inline from the tokens.
66
+
67
+ - CSS targeting the old markup (`<center>`, `.expander` cell inside
68
+ `.button.expand`) no longer matches; style buttons through tokens.
69
+ - A dark-mode override must use `!important` to beat the inline styles.
70
+ - A `<button>` without `href` renders its label in a `<span>`.
71
+
72
+ ## Components
73
+
74
+ - The `<inky>` tag is removed.
75
+ - `inner` is an opaque slot: interpolate it exactly once and never parse or
76
+ alter it.
77
+ - `IGNORED_ON_PASSTHROUGH`, `combine_attributes`, `pass_through_attributes`,
78
+ `bulletproof_button_table` and `BUTTON_PADDING` are removed. Use
79
+ `root_attributes(node, classes:, layout_style:)` and override the private
80
+ `consumed_attributes` to list the attributes your component reads; a
81
+ component whose root is a `TABLE_RESET` table inherits from
82
+ `ActiveMail::Components::TableComponent`.
83
+ - A component must output `inner` exactly once (or have no children):
84
+ otherwise `ActiveMail::Error` is raised instead of silently dropping content.
85
+ Self-closing `<h-line/>`/`<spacer/>` are not valid HTML4 and swallow their
86
+ following siblings; write `<h-line></h-line>`.
87
+ - `id`, `aria-*`, `data-*` and other attributes now pass through on every
88
+ built-in component. On tables carrying `TABLE_RESET`, the component's
89
+ `role="presentation"` wins over the author's `role`.
90
+ - `ActiveMail::Core.new` only takes keywords (`config:`, `components:`,
91
+ `column_count:`, `container_width:`). For plain transpilation prefer the
92
+ memoized `ActiveMail.configuration.core`.
93
+ - Integer settings (`column_count`, `container_width`) raise `TypeError` for a
94
+ non-Integer and `ArgumentError` for a non-positive value. Numeric attributes
95
+ are parsed in strict base 10.
96
+
97
+ ## Quality
98
+
99
+ - `output_dir` must be relative, inside `Rails.root` (symlinks resolved), and
100
+ neither `.` nor contain `..`. `render_all` marks it with an
101
+ `.activemail_previews` file and refuses to clean an existing, non-empty
102
+ directory without that marker: empty your previous output directory once.
103
+ - `PreviewRenderer.render` is removed.
104
+ - `RenderAll.new(root:, config:)` replaces `output_root:`.
105
+ - Rendered files and failures carry the locale when it differs from the
106
+ default (`welcome.fr.html`, `preview#email [fr]`).
107
+ - `Guard#violations` and `assert_email_quality` accept a `Mail::Message` (HTML
108
+ plus message rules) or an HTML string. New built-in rules may flag existing
109
+ emails: `translation_missing`, `translation_missing_text`, `single_root`,
110
+ `html_part_present`, `text_part_present`, `text_part_plain`. Disable any with
111
+ `Guard.new(disable: [...])`.
112
+ - Host-specific checks move to `Guard.new(rules: [Quality::Rule.html(...)])`.
@@ -1,62 +1,53 @@
1
- // Background on both cell and link so the whole bulletproof button is filled and
2
- // clickable. Radius by default — progressive enhancement, clients that don't support it ignore it.
3
- .button table td,
4
- .button a {
5
- background: $am-button-primary-bg;
6
- border-radius: $am-button-radius;
7
- }
1
+ @use "sass:math";
2
+ @use "settings" as *;
8
3
 
9
- .button a {
10
- color: $am-button-color;
11
- font-family: $am-font-family;
12
- font-weight: bold;
4
+ .expander {
5
+ padding: 0 !important;
6
+ visibility: hidden;
7
+ width: 0;
13
8
  }
14
9
 
15
- .button.secondary table td,
16
- .button.secondary a {
17
- background: $am-button-secondary-bg;
18
- color: $am-button-secondary-color;
10
+ .info-box td {
11
+ background-color: $am-info-box-background;
12
+ border-left: 5px solid $am-info-box-border;
13
+ border-radius: $am-box-radius;
14
+ color: $am-info-box-text;
15
+ padding: $am-spacing-md;
19
16
  }
20
17
 
21
- // Border on the cell only — a repeated <a> border would double the line.
22
- .button.secondary table td {
23
- border: 1px solid $am-button-secondary-border;
18
+ .callout-inner {
19
+ background-color: $am-background;
20
+ color: $am-text;
21
+ padding: $am-spacing-md;
24
22
  }
25
23
 
26
- .cta table td,
27
- .cta a {
28
- background: $am-button-primary-bg;
29
- border-radius: $am-button-radius;
24
+ .h-line,
25
+ .h-line th {
26
+ font-size: 1px;
27
+ line-height: 1px;
30
28
  }
31
29
 
32
- .cta a {
33
- color: $am-button-color;
34
- font-family: $am-font-family;
35
- font-weight: bold;
30
+ .h-line {
31
+ border-top: 1px solid $am-border;
36
32
  }
37
33
 
38
- .cta.secondary table td,
39
- .cta.secondary a {
40
- background: $am-button-secondary-bg;
41
- color: $am-button-secondary-color;
34
+ .menu {
35
+ width: auto !important;
42
36
  }
43
37
 
44
- .cta.secondary table td {
45
- border: 1px solid $am-button-secondary-border;
38
+ .menu-item {
39
+ padding: $am-spacing-sm $am-spacing-md;
46
40
  }
47
41
 
48
- .info-box td {
49
- background-color: $am-info-box-bg;
50
- border-left: 5px solid $am-info-box-border;
51
- border-radius: $am-box-radius;
52
- color: $am-info-box-text;
53
- padding: $am-spacing-md;
42
+ .menu-item a {
43
+ text-decoration: none;
54
44
  }
55
45
 
56
- .callout-inner {
57
- background-color: $am-background;
58
- color: $am-text;
59
- padding: $am-spacing-md;
46
+ @for $n from 1 through $am-column-count {
47
+ .block-grid.up-#{$n} > tbody > tr > th,
48
+ .block-grid.up-#{$n} > tbody > tr > td {
49
+ width: math.div(100%, $n);
50
+ }
60
51
  }
61
52
 
62
53
  // Height is set inline per instance; this rule is just a styling anchor.
@@ -1,12 +1,7 @@
1
1
  // Dark mode: prefers-color-scheme (Apple Mail/iOS) + [data-ogsc] (Outlook
2
2
  // Android). Colors derived from tokens — pure black/white crush contrast.
3
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;
4
+ @use "settings" as *;
10
5
 
11
6
  @mixin am-dark-rules {
12
7
  .body,
@@ -39,25 +34,33 @@ $am-dark-link: $am-color-primary !default;
39
34
 
40
35
  .button table td,
41
36
  .button a,
37
+ .button span,
42
38
  .cta table td,
43
- .cta a {
44
- background: $am-color-primary !important;
45
- color: $am-color-button-text !important;
39
+ .cta a,
40
+ .cta span {
41
+ background: $am-button-primary-bg !important;
42
+ color: $am-button-primary-color !important;
46
43
  }
47
44
 
48
- // Restore the secondary fill AND text — the primary rule above forces button-text,
49
- // which would be unreadable on an outline (light) secondary fill.
45
+ // Restore the secondary fill AND text — the primary rule above would be unreadable
46
+ // on a light secondary fill.
50
47
  .button.secondary table td,
51
48
  .button.secondary a,
49
+ .button.secondary span,
52
50
  .cta.secondary table td,
53
- .cta.secondary a {
54
- background: $am-color-secondary !important;
51
+ .cta.secondary a,
52
+ .cta.secondary span {
53
+ background: $am-button-secondary-bg !important;
55
54
  color: $am-button-secondary-color !important;
56
55
  }
57
56
 
58
57
  // Panels keep their inline background unless overridden here, which would
59
58
  // otherwise leave dark text on a dark background.
60
- .callout-inner,
59
+ .callout-inner {
60
+ background-color: $am-dark-panel !important;
61
+ color: $am-dark-text !important;
62
+ }
63
+
61
64
  .info-box,
62
65
  .info-box td {
63
66
  background-color: $am-dark-panel !important;
@@ -102,21 +105,29 @@ $am-dark-link: $am-color-primary !default;
102
105
 
103
106
  [data-ogsc] .button table td,
104
107
  [data-ogsc] .button a,
108
+ [data-ogsc] .button span,
105
109
  [data-ogsc] .cta table td,
106
- [data-ogsc] .cta a {
107
- background: $am-color-primary !important;
108
- color: $am-color-button-text !important;
110
+ [data-ogsc] .cta a,
111
+ [data-ogsc] .cta span {
112
+ background: $am-button-primary-bg !important;
113
+ color: $am-button-primary-color !important;
109
114
  }
110
115
 
111
116
  [data-ogsc] .button.secondary table td,
112
117
  [data-ogsc] .button.secondary a,
118
+ [data-ogsc] .button.secondary span,
113
119
  [data-ogsc] .cta.secondary table td,
114
- [data-ogsc] .cta.secondary a {
115
- background: $am-color-secondary !important;
120
+ [data-ogsc] .cta.secondary a,
121
+ [data-ogsc] .cta.secondary span {
122
+ background: $am-button-secondary-bg !important;
116
123
  color: $am-button-secondary-color !important;
117
124
  }
118
125
 
119
- [data-ogsc] .callout-inner,
126
+ [data-ogsc] .callout-inner {
127
+ background-color: $am-dark-panel !important;
128
+ color: $am-dark-text !important;
129
+ }
130
+
120
131
  [data-ogsc] .info-box,
121
132
  [data-ogsc] .info-box td {
122
133
  background-color: $am-dark-panel !important;