activemail 1.0.0

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