activemail 2.0.0 → 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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +44 -17
  4. data/UPGRADING.md +73 -0
  5. data/app/assets/stylesheets/activemail/_dark.scss +17 -8
  6. data/app/assets/stylesheets/activemail/_grid.scss +38 -14
  7. data/app/assets/stylesheets/activemail/_settings.scss +1 -0
  8. data/app/views/layouts/activemail/_document_head.html.erb +18 -0
  9. data/app/views/layouts/activemail/mailer.html.inky-erb +1 -17
  10. data/lib/activemail/components/button_renderer.rb +8 -4
  11. data/lib/activemail/components/center.rb +8 -6
  12. data/lib/activemail/components/columns.rb +32 -8
  13. data/lib/activemail/components/container.rb +2 -2
  14. data/lib/activemail/inliner/premailer/document.rb +100 -0
  15. data/lib/activemail/inliner/premailer.rb +3 -2
  16. data/lib/activemail/quality/builtin_message_rules.rb +58 -0
  17. data/lib/activemail/quality/builtin_rules.rb +41 -30
  18. data/lib/activemail/quality/configuration.rb +31 -16
  19. data/lib/activemail/quality/guard.rb +34 -14
  20. data/lib/activemail/quality/minitest.rb +24 -11
  21. data/lib/activemail/quality/output_filter.rb +24 -0
  22. data/lib/activemail/quality/render_all.rb +4 -4
  23. data/lib/activemail/quality/rspec.rb +11 -6
  24. data/lib/activemail/quality/rule_exemptions.rb +49 -0
  25. data/lib/activemail/tokens/defaults.rb +2 -1
  26. data/lib/activemail/version.rb +1 -1
  27. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -17
  28. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -9
  29. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -8
  30. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c37e5bd4d1c2746462f33f9b221a2734081ec4f89be697f77e22f8402767c879
4
- data.tar.gz: 69b5e178c7521b8f741a504af3c4d5524373b68b80cbe44af35a38a168e77389
3
+ metadata.gz: 3dc3ba94cb3bbe53858813d40a054636f6b445a39fa7c52fcd0a8839e1fb6af0
4
+ data.tar.gz: 0a9f87dd6be3ed968f7a24d0110e0a59d52074d0a582ee8dd69ee430bf126784
5
5
  SHA512:
6
- metadata.gz: 2611a2f9405fcdb5e41de1f5817b603d3574ccbc5a0607cd45b22ec85f34cf283d3bd8bfdb22786b48e8248b6b941935a126466fb83a5e70c25ce144be41b64b
7
- data.tar.gz: 7d398eb0eaa80095458a9eb3811aa906f85d212abb6c784166edceeb3249c91f33d3e011efe55b9f4be3fd6e24aadf0246539f0be5f29dc6e9ca28a297475bd9
6
+ metadata.gz: 71a238775cdcfee13478fded9dee238e7abc374bd481ff0a8e4995616f329d6490efe939b0fc4bd4d8ae4fb8ab2b8622f44b162aa89c94fb1152570d0f1a8a9b
7
+ data.tar.gz: ef6f072e79dd25da4149fd47db1abbc33b0c97afd198835c97d36b5f1624e23e2245affef488fc931b323ba3e1a9842a45f3177c4b14abf1740d9121d351da42
data/CHANGELOG.md CHANGED
@@ -5,7 +5,57 @@ 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
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
9
59
 
10
60
  Breaking release: see [UPGRADING.md](UPGRADING.md).
11
61
 
@@ -191,5 +241,7 @@ toolkit for Rails.
191
241
  - Rails `>= 7.1` (tested up to 8.1).
192
242
  - Nokogiri `>= 1.16`.
193
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
194
246
  [1.0.1]: https://github.com/AdVitam/activemail/compare/v1.0.0...v1.0.1
195
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', '~> 2.0'
45
+ gem 'activemail', '~> 2.1'
46
46
  ```
47
47
 
48
48
  ```bash
@@ -136,7 +136,9 @@ Override at three levels, cheapest first:
136
136
  3. **`bin/rails g activemail:views`** — eject the default layout + partials
137
137
  (`app/views/layouts/activemail/*`); a same-named file in your app wins by
138
138
  Rails path precedence. Put your logo/header/footer here — those are the app's
139
- 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.
140
142
 
141
143
  Dark mode ships on: a `<style>` block keys off `prefers-color-scheme: dark` (Apple
142
144
  Mail/iOS) and Outlook's `[data-ogsc]`, with surfaces derived from your tokens.
@@ -147,7 +149,9 @@ Email clients (Gmail mobile, Orange.fr) strip `<style>`, so critical CSS must be
147
149
  inlined. When a mailer renders an inky HTML template, ActiveMail transpiles the
148
150
  whole body (view + layout) and runs the configured inliner once. Text parts,
149
151
  attachments and non-ActiveMail emails are never touched; your `<style>`
150
- enhancement block (media queries, dark mode) is preserved.
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.
151
155
 
152
156
  The layout embeds the compiled stylesheets with `activemail_inline_styles`
153
157
  (Sprockets or Propshaft); pass your own on top of the framework's:
@@ -182,11 +186,11 @@ Fluid-hybrid wrapper, capped at `container_width`, wrapped in an MSO ghost table
182
186
  ```
183
187
 
184
188
  ```html
185
- <!--[if mso | IE]><table role="presentation" align="center" width="600">...<![endif]-->
189
+ <!--[if mso]><table role="presentation" align="center" width="600">...<![endif]-->
186
190
  <table class="container" align="center" style="width:100%;max-width:600px;margin:0 auto;">
187
191
  <tbody><tr><td>...</td></tr></tbody>
188
192
  </table>
189
- <!--[if mso | IE]></td></tr></table><![endif]-->
193
+ <!--[if mso]></td></tr></table><![endif]-->
190
194
  ```
191
195
 
192
196
  ### `<row>` / `<columns>`
@@ -201,16 +205,24 @@ media-query enhancement.
201
205
  ```
202
206
 
203
207
  Column widths are computed as `container_width × large / column_count`, capped
204
- at `container_width`, **with no gutter model**: two `large="6"` columns sit
205
- edge-to-edge (300px + 300px in a 600px container). Add padding inside your
206
- columns for gutters, and keep the `large` sizes of a row summing to at most
207
- `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`).
208
219
 
209
220
  ### `<button>`
210
221
 
211
222
  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;
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;
214
226
  `expand` makes it full width; without `href` the label renders as a `<span>`.
215
227
 
216
228
  ```html
@@ -220,8 +232,8 @@ border sits on the cell only. `class="secondary"` selects the secondary variant;
220
232
  ```html
221
233
  <table class="secondary button"><tbody><tr><td>
222
234
  <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>
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>
225
237
  </td>
226
238
  </tr></tbody></table>
227
239
  </td></tr></tbody></table>
@@ -258,7 +270,7 @@ border sits on the cell only. `class="secondary"` selects the secondary variant;
258
270
 
259
271
  - `<wrapper class="header">` → `<table class="header wrapper" align="center" style="width:100%;">` with a `.wrapper-inner` cell.
260
272
  - `<h-line>` → a full-width single-cell table drawn as a 1px rule in the `border` color.
261
- - `<center>` adds `align="center"` and `.float-center` to its element children (and `.float-center` to nested menu items).
273
+ - `<center>` renders a `<center align="center" class="float-center">` around its content and leaves the content untouched.
262
274
 
263
275
  ### `<raw>`
264
276
 
@@ -367,8 +379,18 @@ expect(rendered_html).to be_a_valid_email
367
379
 
368
380
  The Guard checks byte size (Gmail clips ~102 KB), parse errors,
369
381
  `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:
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.
372
394
 
373
395
  ```ruby
374
396
  signature = ActiveMail::Quality::Rule.html(:single_signature) do |doc, _html|
@@ -377,6 +399,11 @@ end
377
399
 
378
400
  ActiveMail::Quality.configure do |c|
379
401
  c.required_previews = %w[welcome_mailer#welcome]
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
+ }
380
407
  c.guard = ActiveMail::Quality::Guard.new(max_bytes: 90_000, disable: %i[img_alt], rules: [signature])
381
408
  c.locales = %i[fr en] # or ->(preview) { [...] }; each preview gets params[:locale]
382
409
  c.formats = %i[html text]
data/UPGRADING.md CHANGED
@@ -1,3 +1,76 @@
1
+ # Upgrading to ActiveMail 2.1
2
+
3
+ ## Gemfile
4
+
5
+ ```ruby
6
+ gem 'activemail', '~> 2.1'
7
+ ```
8
+
9
+ ## Outer gutter
10
+
11
+ Rows now keep content off the screen edge: the first and last column of a row
12
+ get a 16px (`container_gutter`) border-box padding on their outer side, inlined
13
+ like every other framework rule. Columns still sum to `container_width`; the
14
+ text area is 16px narrower on each side.
15
+
16
+ - To keep edge-to-edge columns: `config.tokens.spacing :container_gutter, '0px'`
17
+ (or `$am-container-gutter: 0`).
18
+ - Content you padded by hand to avoid the edge now has the padding twice:
19
+ remove it.
20
+ - A nested row does not repeat the outer gutter. The `.columns.first.last` SCSS
21
+ rule is gone; ejected `_grid.scss` copies (`activemail:styles`) must be
22
+ re-ejected.
23
+
24
+ ## Outlook markup
25
+
26
+ - Conditional comments are `<!--[if mso]>` (`mso` alone targets Outlook
27
+ for Windows). Tests matching `[if mso | IE]` must change.
28
+ - Each column ghost cell wraps its column in a `<table><tr>`. A nested column
29
+ is sized against the content box of its enclosing column: `large="6"` in a
30
+ lone `large="6"` is 134px (300px minus two 16px gutters, halved), not 300px.
31
+
32
+ ## Buttons
33
+
34
+ - The fill and the radius are on the cell, not on the `<a>`. The `<a>` carries
35
+ `display:inline-block`, `font-family` (the `body` font token) and
36
+ `line-height:1.25`; the cell gets `mso-padding-alt`.
37
+ - A dark-mode override of the button fill must target `table td`, not `a`.
38
+
39
+ ## Center
40
+
41
+ `<center>` no longer adds `align="center"` and `.float-center` to its children
42
+ (nor to nested menu items): it renders one `<center align="center">` and leaves
43
+ its content as written. Put `class="float-center"` or `align="center"` on the
44
+ children that need it.
45
+
46
+ ## Layouts
47
+
48
+ The `<head>` of the default layout moved to `layouts/activemail/_document_head`,
49
+ with `<title><%= message.subject %></title>`. The generated layouts
50
+ (`activemail:install`, `--haml`, `--slim`) render that partial and the `_head`
51
+ and `_footer` partials: regenerate yours or add the `<title>`. The partial reads
52
+ `message`, so it only renders inside a mailer view.
53
+
54
+ ## Quality
55
+
56
+ New built-in rules flag emails that passed before: `title`, `anchor_href`,
57
+ `placeholder`, `unsupported_color`, `text_entities`. Disable any with
58
+ `Guard.new(disable: [...])`.
59
+
60
+ A `NullMail` (a mailer that returned without sending) is now a `declined`
61
+ violation, in `Guard`, `render_all`, `assert_email_quality`,
62
+ `assert_quality_for_all_previews` and `be_a_valid_email`. List the previews that
63
+ decline on purpose:
64
+
65
+ ```ruby
66
+ ActiveMail::Quality.configure { |c| c.allow_declined = %w[reminder_mailer#maybe_skipped] }
67
+ ```
68
+
69
+ or pass `allow_declined: true` to `Guard#violations`, `Guard#valid?`,
70
+ `assert_email_quality` and `be_a_valid_email`.
71
+
72
+ ---
73
+
1
74
  # Upgrading to ActiveMail 2.0
2
75
 
3
76
  ## Gemfile
@@ -32,25 +32,30 @@
32
32
  color: $am-dark-link !important;
33
33
  }
34
34
 
35
+ // The fill lives on the cell alone: a filled link would square off its rounded corners.
35
36
  .button table td,
37
+ .cta table td {
38
+ background: $am-button-primary-bg !important;
39
+ }
40
+
36
41
  .button a,
37
42
  .button span,
38
- .cta table td,
39
43
  .cta a,
40
44
  .cta span {
41
- background: $am-button-primary-bg !important;
42
45
  color: $am-button-primary-color !important;
43
46
  }
44
47
 
45
48
  // Restore the secondary fill AND text — the primary rule above would be unreadable
46
49
  // on a light secondary fill.
47
50
  .button.secondary table td,
51
+ .cta.secondary table td {
52
+ background: $am-button-secondary-bg !important;
53
+ }
54
+
48
55
  .button.secondary a,
49
56
  .button.secondary span,
50
- .cta.secondary table td,
51
57
  .cta.secondary a,
52
58
  .cta.secondary span {
53
- background: $am-button-secondary-bg !important;
54
59
  color: $am-button-secondary-color !important;
55
60
  }
56
61
 
@@ -104,22 +109,26 @@
104
109
  }
105
110
 
106
111
  [data-ogsc] .button table td,
112
+ [data-ogsc] .cta table td {
113
+ background: $am-button-primary-bg !important;
114
+ }
115
+
107
116
  [data-ogsc] .button a,
108
117
  [data-ogsc] .button span,
109
- [data-ogsc] .cta table td,
110
118
  [data-ogsc] .cta a,
111
119
  [data-ogsc] .cta span {
112
- background: $am-button-primary-bg !important;
113
120
  color: $am-button-primary-color !important;
114
121
  }
115
122
 
116
123
  [data-ogsc] .button.secondary table td,
124
+ [data-ogsc] .cta.secondary table td {
125
+ background: $am-button-secondary-bg !important;
126
+ }
127
+
117
128
  [data-ogsc] .button.secondary a,
118
129
  [data-ogsc] .button.secondary span,
119
- [data-ogsc] .cta.secondary table td,
120
130
  [data-ogsc] .cta.secondary a,
121
131
  [data-ogsc] .cta.secondary span {
122
- background: $am-button-secondary-bg !important;
123
132
  color: $am-button-secondary-color !important;
124
133
  }
125
134
 
@@ -1,7 +1,6 @@
1
1
  @use "settings" as *;
2
2
 
3
- // Gutters are unconditional padding (inlined by the CSS inliner): the gem emits
4
- // none, and they must not depend on media queries (stripped by Gmail mobile).
3
+ // Gutters are plain padding, inlined: Gmail mobile strips media queries.
5
4
 
6
5
  .container {
7
6
  width: 100%;
@@ -17,11 +16,8 @@
17
16
  width: 100%;
18
17
  }
19
18
 
20
- // Gutters: half on each side, outer edges collapsed via first/last so content
21
- // stays flush with the container.
22
- // box-sizing keeps the gutter inside the inline max-width — without it a 300px
23
- // 2-col cell + 8px padding becomes 316px and the row overflows ~616px, wrapping
24
- // in clients that honor inline padding.
19
+ // border-box keeps each gutter inside the inline max-width, else the row overflows and wraps;
20
+ // the container takes no padding for the same reason.
25
21
  .columns {
26
22
  box-sizing: border-box;
27
23
  padding-left: $am-gutter;
@@ -29,23 +25,51 @@
29
25
  }
30
26
 
31
27
  .columns.first {
32
- padding-left: 0;
28
+ padding-left: $am-container-gutter;
33
29
  }
34
30
 
35
31
  .columns.last {
36
- padding-right: 0;
32
+ padding-right: $am-container-gutter;
37
33
  }
38
34
 
39
- // A single full-width column carries no side gutter.
40
- .columns.first.last {
35
+ // A nested row already sits inside its parent column's padding.
36
+ .columns .columns.first {
41
37
  padding-left: 0;
38
+ }
39
+
40
+ .columns .columns.last {
42
41
  padding-right: 0;
43
42
  }
44
43
 
45
- // Collapse removes gutters on demand (e.g. edge-to-edge images). Columns are not
46
- // direct children of .row — the real markup nests them as <th class="columns">
47
- // under tbody > tr — so this must be a descendant selector, not a child one.
44
+ // Descendant selector: columns render as <th> under tbody > tr, not as children of .row.
48
45
  .row.collapse .columns {
49
46
  padding-left: 0;
50
47
  padding-right: 0;
51
48
  }
49
+
50
+ // Below the container width every full row stacks, so each top-level column edge is an outer one.
51
+ // Nested columns may still sit side by side and keep the regular gutters. Gmail strips this query.
52
+ @media only screen and (max-width: $am-breakpoint - 1px) {
53
+ .columns {
54
+ padding-left: $am-container-gutter !important;
55
+ padding-right: $am-container-gutter !important;
56
+ }
57
+
58
+ .columns .columns {
59
+ padding-left: $am-gutter !important;
60
+ padding-right: $am-gutter !important;
61
+ }
62
+
63
+ .columns .columns.first {
64
+ padding-left: 0 !important;
65
+ }
66
+
67
+ .columns .columns.last {
68
+ padding-right: 0 !important;
69
+ }
70
+
71
+ .row.collapse .columns {
72
+ padding-left: 0 !important;
73
+ padding-right: 0 !important;
74
+ }
75
+ }
@@ -34,6 +34,7 @@ $am-box-radius: $am-radius-box !default;
34
34
  // aligned with the transpiled markup (ghost tables, column max-width).
35
35
  $am-container-width: $am-grid-container-width !default;
36
36
  $am-gutter: $am-spacing-sm !default;
37
+ $am-container-gutter: $am-spacing-container-gutter !default;
37
38
  $am-breakpoint: $am-container-width !default;
38
39
 
39
40
  // Dark mode
@@ -0,0 +1,18 @@
1
+ <head>
2
+ <meta charset="utf-8">
3
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <meta name="x-apple-disable-message-reformatting">
5
+ <meta name="color-scheme" content="light dark">
6
+ <meta name="supported-color-schemes" content="light dark">
7
+ <title><%= message.subject %></title>
8
+ <!--[if mso]>
9
+ <noscript>
10
+ <xml>
11
+ <o:OfficeDocumentSettings>
12
+ <o:PixelsPerInch>96</o:PixelsPerInch>
13
+ </o:OfficeDocumentSettings>
14
+ </xml>
15
+ </noscript>
16
+ <![endif]-->
17
+ <%= activemail_inline_styles %>
18
+ </head>
@@ -1,22 +1,6 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="<%= I18n.locale %>" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta name="x-apple-disable-message-reformatting">
7
- <meta name="color-scheme" content="light dark">
8
- <meta name="supported-color-schemes" content="light dark">
9
- <!--[if mso]>
10
- <noscript>
11
- <xml>
12
- <o:OfficeDocumentSettings>
13
- <o:PixelsPerInch>96</o:PixelsPerInch>
14
- </o:OfficeDocumentSettings>
15
- </xml>
16
- </noscript>
17
- <![endif]-->
18
- <%= activemail_inline_styles %>
19
- </head>
3
+ <%= render "layouts/activemail/document_head" %>
20
4
 
21
5
  <body style="margin:0;padding:0;word-spacing:normal;">
22
6
  <div role="article" aria-roledescription="email" lang="<%= I18n.locale %>" style="width:100%;">
@@ -14,6 +14,8 @@ module ActiveMail
14
14
  abstract!
15
15
 
16
16
  CONTROL_PADDING = 'padding:12px 24px;'
17
+ MSO_CELL_PADDING = 'mso-padding-alt:12px 24px;'
18
+ LINE_HEIGHT = '1.25'
17
19
 
18
20
  sig { abstract.returns(String) }
19
21
  def root_class; end
@@ -38,7 +40,8 @@ module ActiveMail
38
40
  LINK_ATTRIBUTES
39
41
  end
40
42
 
41
- # Padding on the control makes the whole button a clickable target.
43
+ # Padding on the control makes the whole button a clickable target; Outlook Word
44
+ # ignores it there, so the cell repeats it as `mso-padding-alt`.
42
45
  sig { params(node: Nokogiri::XML::Node, inner: String, style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
43
46
  def control(node, inner, style, expand)
44
47
  css = control_style(style, expand)
@@ -48,17 +51,18 @@ module ActiveMail
48
51
  anchor(node, inner, before: extra, after: style_attribute(node, css))
49
52
  end
50
53
 
51
- # The outline lives on the cell only: repeating it on the control would double the line.
54
+ # Fill, corners and outline live on the cell only: repeating them on the control would double them.
52
55
  sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
53
56
  def cell_style(style, expand)
54
57
  border = style.border ? "border:1px solid #{style.border};" : ''
55
- "background:#{style.background};border-radius:#{style.radius};#{border}#{'text-align:center;' if expand}"
58
+ "background:#{style.background};border-radius:#{style.radius};#{border}#{MSO_CELL_PADDING}#{'text-align:center;' if expand}"
56
59
  end
57
60
 
58
61
  sig { params(style: ActiveMail::Tokens::ButtonStyle, expand: T::Boolean).returns(String) }
59
62
  def control_style(style, expand)
60
63
  "display:#{expand ? 'block' : 'inline-block'};text-decoration:none;#{CONTROL_PADDING}" \
61
- "background:#{style.background};color:#{style.color};font-weight:bold;border-radius:#{style.radius};"
64
+ "font-family:#{escape_attr(core.config.tokens.font!(:body))};line-height:#{LINE_HEIGHT};" \
65
+ "color:#{style.color};font-weight:bold;"
62
66
  end
63
67
  end
64
68
  end
@@ -10,12 +10,14 @@ module ActiveMail
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- node.elements.each do |child|
14
- child['align'] = 'center'
15
- child['class'] = combine_classes(child, 'float-center')
16
- end
17
- node.css('.menu-item').each { |item| item['class'] = combine_classes(item, 'float-center') }
18
- %(<center#{root_attributes(node, classes: nil)}>#{inner}</center>)
13
+ %(<center#{root_attributes(node, classes: 'float-center')} align="center">#{inner}</center>)
14
+ end
15
+
16
+ private
17
+
18
+ sig { override.returns(T::Array[String]) }
19
+ def consumed_attributes
20
+ ['align']
19
21
  end
20
22
  end
21
23
  end
@@ -11,12 +11,12 @@ module ActiveMail
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
13
  small, large = column_sizes(node)
14
- width_px = ghost_width(large)
14
+ width_px = ghost_width(node, large)
15
15
 
16
16
  [
17
- %(<!--[if mso | IE]><td width="#{width_px}" valign="top"><![endif]-->),
17
+ %(<!--[if mso]><td width="#{width_px}" valign="top"><table #{TABLE_RESET} width="#{width_px}"><tr><![endif]-->),
18
18
  column_markup(node, inner, column_classes(node, small, large), width_px, expander(node, large)),
19
- '<!--[if mso | IE]></td><![endif]-->'
19
+ '<!--[if mso]></tr></table></td><![endif]-->'
20
20
  ].join
21
21
  end
22
22
 
@@ -65,11 +65,35 @@ module ActiveMail
65
65
  %(<th class="expander"></th>)
66
66
  end
67
67
 
68
- # Clamp: an oversized `large` would push the MSO ghost cell past the
69
- # container and force a wrap in Outlook Word.
70
- sig { params(large: Integer).returns(Integer) }
71
- def ghost_width(large)
72
- [((large.to_f / column_count) * container_width).round, container_width].min
68
+ # Columns are transformed leaves first, so the enclosing <columns> are still raw nodes whose
69
+ # sizes and positions are readable. A nested row shares the content box of its parent column.
70
+ sig { params(node: Nokogiri::XML::Node, large: Integer).returns(Integer) }
71
+ def ghost_width(node, large)
72
+ available = node.ancestors('columns').reverse.reduce(Rational(container_width)) do |width, ancestor|
73
+ share_of(width, column_sizes(ancestor).last) - horizontal_padding(ancestor)
74
+ end
75
+ share_of(available, large).round
76
+ end
77
+
78
+ # Clamped: an oversized `large` would push the MSO ghost cell past its parent and force a wrap in Outlook Word.
79
+ sig { params(width: Rational, share: Integer).returns(Rational) }
80
+ def share_of(width, share)
81
+ width * Rational([share, column_count].min, column_count)
82
+ end
83
+
84
+ # Mirrors _grid.scss, whose padding is inlined on each column.
85
+ sig { params(node: Nokogiri::XML::Node).returns(Rational) }
86
+ def horizontal_padding(node)
87
+ return Rational(0) if node.ancestors('row').any? { |row| class?(row, 'collapse') }
88
+
89
+ siblings = column_siblings(node)
90
+ outer_side = node.ancestors('columns').empty? ? spacing(:container_gutter) : Rational(0)
91
+ [siblings.first, siblings.last].sum(Rational(0)) { |edge| edge == node ? outer_side : spacing(:sm) }
92
+ end
93
+
94
+ sig { params(name: Symbol).returns(Rational) }
95
+ def spacing(name)
96
+ Rational(core.config.tokens.spacing!(name).delete_suffix('px'))
73
97
  end
74
98
 
75
99
  sig { params(node: Nokogiri::XML::Node, inner: String, classes: String, width_px: Integer, expander: String).returns(String) }
@@ -14,10 +14,10 @@ module ActiveMail
14
14
  attributes = root_attributes(node, classes: 'container', layout_style: "width:100%;max-width:#{width}px;margin:0 auto;")
15
15
  # Outlook Word ignores max-width; the MSO ghost table pins the width.
16
16
  [
17
- %(<!--[if mso | IE]><table #{TABLE_RESET} align="center" width="#{width}"><tr><td><![endif]-->),
17
+ %(<!--[if mso]><table #{TABLE_RESET} align="center" width="#{width}"><tr><td><![endif]-->),
18
18
  %(<table#{attributes} #{TABLE_RESET} align="center">),
19
19
  %(<tbody><tr><td>#{inner}</td></tr></tbody></table>),
20
- '<!--[if mso | IE]></td></tr></table><![endif]-->'
20
+ '<!--[if mso]></td></tr></table><![endif]-->'
21
21
  ].join
22
22
  end
23
23