activemail 1.2.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -2
  3. data/README.md +141 -56
  4. data/UPGRADING.md +185 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +44 -24
  7. data/app/assets/stylesheets/activemail/_grid.scss +40 -14
  8. data/app/assets/stylesheets/activemail/_settings.scss +23 -10
  9. data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
  10. data/app/assets/stylesheets/activemail/activemail.scss +6 -5
  11. data/app/helpers/active_mail/styles_helper.rb +14 -15
  12. data/app/views/layouts/activemail/_document_head.html.erb +18 -0
  13. data/app/views/layouts/activemail/mailer.html.inky-erb +1 -22
  14. data/lib/activemail/asset_pipeline/base.rb +47 -0
  15. data/lib/activemail/asset_pipeline/null.rb +19 -0
  16. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  17. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  18. data/lib/activemail/asset_pipeline.rb +35 -0
  19. data/lib/activemail/components/base.rb +35 -39
  20. data/lib/activemail/components/block_grid.rb +11 -4
  21. data/lib/activemail/components/button.rb +5 -28
  22. data/lib/activemail/components/button_renderer.rb +69 -0
  23. data/lib/activemail/components/callout.rb +4 -5
  24. data/lib/activemail/components/center.rb +10 -10
  25. data/lib/activemail/components/columns.rb +58 -15
  26. data/lib/activemail/components/container.rb +13 -6
  27. data/lib/activemail/components/cta.rb +9 -33
  28. data/lib/activemail/components/h_line.rb +3 -5
  29. data/lib/activemail/components/info_box.rb +6 -12
  30. data/lib/activemail/components/menu.rb +3 -4
  31. data/lib/activemail/components/menu_item.rb +10 -3
  32. data/lib/activemail/components/row.rb +3 -4
  33. data/lib/activemail/components/spacer.rb +16 -12
  34. data/lib/activemail/components/table_component.rb +23 -0
  35. data/lib/activemail/components/wrapper.rb +10 -4
  36. data/lib/activemail/configuration.rb +43 -57
  37. data/lib/activemail/errors.rb +15 -0
  38. data/lib/activemail/inliner/base.rb +2 -10
  39. data/lib/activemail/inliner/null.rb +0 -5
  40. data/lib/activemail/inliner/premailer/document.rb +100 -0
  41. data/lib/activemail/inliner/premailer.rb +3 -2
  42. data/lib/activemail/inliner/roadie.rb +1 -1
  43. data/lib/activemail/inliner.rb +33 -0
  44. data/lib/activemail/parse_error_reporter.rb +5 -4
  45. data/lib/activemail/pipeline.rb +26 -0
  46. data/lib/activemail/quality/builtin_message_rules.rb +58 -0
  47. data/lib/activemail/quality/builtin_rules.rb +165 -0
  48. data/lib/activemail/quality/configuration.rb +103 -8
  49. data/lib/activemail/quality/guard.rb +61 -83
  50. data/lib/activemail/quality/minitest.rb +56 -27
  51. data/lib/activemail/quality/output_dir.rb +52 -0
  52. data/lib/activemail/quality/output_filter.rb +24 -0
  53. data/lib/activemail/quality/preview_renderer.rb +46 -33
  54. data/lib/activemail/quality/render_all.rb +95 -39
  55. data/lib/activemail/quality/rspec.rb +14 -13
  56. data/lib/activemail/quality/rule.rb +47 -0
  57. data/lib/activemail/quality/rule_exemptions.rb +49 -0
  58. data/lib/activemail/rails/engine.rb +13 -13
  59. data/lib/activemail/rails/rendering.rb +51 -0
  60. data/lib/activemail/rails/template_handler.rb +3 -1
  61. data/lib/activemail/tokens/button_style.rb +1 -2
  62. data/lib/activemail/tokens/defaults.rb +78 -0
  63. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  64. data/lib/activemail/tokens.rb +19 -58
  65. data/lib/activemail/version.rb +1 -1
  66. data/lib/activemail.rb +89 -61
  67. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  68. data/lib/generators/activemail/templates/initializer.rb +0 -3
  69. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -19
  70. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -11
  71. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -10
  72. data/lib/tasks/activemail.rake +4 -5
  73. metadata +22 -4
  74. data/lib/activemail/components/inky.rb +0 -18
  75. data/lib/activemail/inliner/interceptor.rb +0 -51
  76. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
@@ -1,48 +1,24 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'button_renderer'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- # Colors read from tokens at transform time (runtime config), not load-time constants.
9
- # Styles are inlined so the button survives clients that strip <style> (Gmail mobile…).
10
- class Cta < Base
8
+ class Cta < ButtonRenderer
11
9
  extend T::Sig
12
10
 
13
- sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
11
+ sig { override.returns(String) }
12
+ def root_class
13
+ 'cta'
14
+ end
15
+
16
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
14
17
  def transform(node, inner)
15
18
  # A CTA without a link is an authoring bug — surface it at render time.
16
19
  raise ArgumentError, '<cta> requires an href attribute' if node.attr('href').to_s.strip.empty?
17
20
 
18
- style = ActiveMail.tokens.button_style(class?(node, 'secondary') ? :secondary : :primary)
19
- anchor = %(<a href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)} ) +
20
- %(style="#{link_style(style)}">#{inner}</a>)
21
- bulletproof_button_table(
22
- outer_classes: combine_classes(node, 'cta'),
23
- inner: anchor,
24
- cell_style: cell_style(style)
25
- )
26
- end
27
-
28
- private
29
-
30
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
31
- def cell_style(style)
32
- "background:#{style.background};border-radius:#{style.radius};#{border_css(style)}"
33
- end
34
-
35
- # No border here: it lives on the cell only — a repeated <a> border doubles the line.
36
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
37
- def link_style(style)
38
- "display:inline-block;text-decoration:none;#{BUTTON_PADDING}" \
39
- "background:#{style.background};color:#{style.color};font-weight:bold;" \
40
- "border-radius:#{style.radius};"
41
- end
42
-
43
- sig { params(style: ActiveMail::Tokens::ButtonStyle).returns(String) }
44
- def border_css(style)
45
- style.border ? "border:1px solid #{style.border};" : ''
21
+ super
46
22
  end
47
23
  end
48
24
  end
@@ -1,18 +1,16 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class HLine < Base
8
+ class HLine < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
12
  def transform(node, _inner)
13
- classes = combine_classes(node, 'h-line')
14
- attributes = pass_through_attributes(node)
15
- %(<table #{attributes}class="#{classes}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr><th>&nbsp;</th></tr></tbody></table>)
13
+ %(<table#{root_attributes(node, classes: 'h-line', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr><th>&nbsp;</th></tr></tbody></table>)
16
14
  end
17
15
  end
18
16
  end
@@ -1,19 +1,18 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
8
  # Colors read from tokens at transform time (runtime config), not load-time constants.
9
- class InfoBox < Base
9
+ class InfoBox < TableComponent
10
10
  extend T::Sig
11
11
 
12
12
  sig { override.overridable.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
13
13
  def transform(node, inner)
14
- classes = combine_classes(node, 'info-box')
15
14
  [
16
- %(<table class="#{classes}" #{TABLE_RESET} style="width:100%;"><tbody><tr>),
15
+ %(<table#{root_attributes(node, classes: 'info-box', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>),
17
16
  %(<td style="#{cell_style}">#{inner}</td>),
18
17
  '</tr></tbody></table>'
19
18
  ].join
@@ -21,16 +20,11 @@ module ActiveMail
21
20
 
22
21
  private
23
22
 
24
- # Box-scoped tokens (fall back to page-level ones) so a host can give the box
25
- # a distinct surface without colliding with :background/:border/:text.
26
23
  sig { returns(String) }
27
24
  def cell_style
28
- tokens = ActiveMail.tokens
29
- background = tokens.color(:info_box_background) || tokens.color!(:background)
30
- border = tokens.color(:info_box_border) || tokens.color!(:border)
31
- text = tokens.color(:info_box_text) || tokens.color!(:text)
32
- "background-color:#{background};border-left:5px solid #{border};border-radius:#{tokens.radius!(:box)};" \
33
- "color:#{text};padding:#{tokens.spacing!(:md)};"
25
+ tokens = core.config.tokens
26
+ "background-color:#{tokens.color!(:info_box_background)};border-left:5px solid #{tokens.color!(:info_box_border)};" \
27
+ "border-radius:#{tokens.radius!(:box)};color:#{tokens.color!(:info_box_text)};padding:#{tokens.spacing!(:md)};"
34
28
  end
35
29
  end
36
30
  end
@@ -1,18 +1,17 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Menu < Base
8
+ class Menu < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'menu')
14
13
  [
15
- %(<table #{attributes} #{TABLE_RESET}#{style_attribute(node)}><tbody><tr><td>),
14
+ %(<table#{root_attributes(node, classes: 'menu')} #{TABLE_RESET}><tbody><tr><td>),
16
15
  %(<table #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>),
17
16
  '</td></tr></tbody></table>'
18
17
  ].join
@@ -10,11 +10,18 @@ module ActiveMail
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'menu-item')
13
+ attributes = root_attributes(node, classes: 'menu-item')
14
14
  # No href → a non-link item, not a broken <a href="">; mirrors <button>.
15
- content = node.attr('href') ? %(<a href="#{escape_attr(node.attr('href'))}"#{link_attributes(node)}>#{inner}</a>) : inner
15
+ content = node.attr('href') ? anchor(node, inner) : inner
16
16
  th = ::ActiveMail::Core::INTERIM_TH_TAG
17
- %(<#{th} #{attributes}#{style_attribute(node)}>#{content}</#{th}>)
17
+ %(<#{th}#{attributes}>#{content}</#{th}>)
18
+ end
19
+
20
+ private
21
+
22
+ sig { override.returns(T::Array[String]) }
23
+ def consumed_attributes
24
+ LINK_ATTRIBUTES
18
25
  end
19
26
  end
20
27
  end
@@ -1,17 +1,16 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Row < Base
8
+ class Row < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'row')
14
- %(<table #{attributes} #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>#{inner}</tr></tbody></table>)
13
+ %(<table#{root_attributes(node, classes: 'row', layout_style: 'width:100%;')} #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>)
15
14
  end
16
15
  end
17
16
  end
@@ -1,41 +1,45 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Spacer < Base
8
+ class Spacer < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
12
  def transform(node, _inner)
13
- classes = combine_classes(node, 'spacer')
14
13
  size_sm = node.attr('size-sm')
15
14
  size_lg = node.attr('size-lg')
16
15
 
17
- return build_table(node, classes, nil, size_for(node.attr('size'))) unless size_sm || size_lg
16
+ return build_table(node, 'spacer', size_for(node.attr('size')), skip: []) unless size_sm || size_lg
18
17
 
19
- html = +''
20
- html << build_table(node, classes, 'hide-for-large', size_for(size_sm)) if size_sm
21
- html << build_table(node, classes, 'show-for-large', size_for(size_lg)) if size_lg
22
- html
18
+ tables = []
19
+ tables << ['spacer hide-for-large', size_for(size_sm)] if size_sm
20
+ tables << ['spacer show-for-large', size_for(size_lg)] if size_lg
21
+ tables.each_with_index.map { |(classes, size), index| build_table(node, classes, size, skip: index.zero? ? [] : %w[id]) }.join
23
22
  end
24
23
 
25
24
  private
26
25
 
26
+ sig { override.returns(T::Array[String]) }
27
+ def consumed_attributes
28
+ [*super, 'size', 'size-sm', 'size-lg']
29
+ end
30
+
27
31
  sig { params(value: T.untyped).returns(Integer) }
28
32
  def size_for(value)
29
33
  positive_int(value) || 16
30
34
  end
31
35
 
32
- sig { params(node: Nokogiri::XML::Node, classes: String, extra: T.nilable(String), size: Integer).returns(String) }
33
- def build_table(node, classes, extra, size)
34
- css_class = extra ? "#{classes} #{extra}" : classes
36
+ # A repeated id would be invalid HTML: only the first table carries it.
37
+ sig { params(node: Nokogiri::XML::Node, classes: String, size: Integer, skip: T::Array[String]).returns(String) }
38
+ def build_table(node, classes, size, skip:)
35
39
  # mso-line-height-rule:exactly keeps Outlook from inflating the spacer.
36
40
  style = "font-size:#{size}px;line-height:#{size}px;mso-line-height-rule:exactly;"
37
41
  [
38
- %(<table class="#{css_class}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>),
42
+ %(<table#{root_attributes(node, classes: classes, layout_style: 'width:100%;', skip: skip)} #{TABLE_RESET}><tbody><tr>),
39
43
  %(<td height="#{size}" style="#{style}">&nbsp;</td>),
40
44
  '</tr></tbody></table>'
41
45
  ].join
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ # Root element is a layout table: its reset attributes are fixed, not author-overridable.
9
+ class TableComponent < Base
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ abstract!
14
+
15
+ private
16
+
17
+ sig { override.overridable.returns(T::Array[String]) }
18
+ def consumed_attributes
19
+ TABLE_RESET_ATTRIBUTES
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,21 +1,27 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'base'
4
+ require_relative 'table_component'
5
5
 
6
6
  module ActiveMail
7
7
  module Components
8
- class Wrapper < Base
8
+ class Wrapper < TableComponent
9
9
  extend T::Sig
10
10
 
11
11
  sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
12
  def transform(node, inner)
13
- attributes = combine_attributes(node, 'wrapper')
14
13
  [
15
- %(<table #{attributes} #{TABLE_RESET} align="center"#{style_attribute(node, 'width:100%;')}><tbody><tr>),
14
+ %(<table#{root_attributes(node, classes: 'wrapper', layout_style: 'width:100%;')} #{TABLE_RESET} align="center"><tbody><tr>),
16
15
  %(<td class="wrapper-inner">#{inner}</td></tr></tbody></table>)
17
16
  ].join
18
17
  end
18
+
19
+ private
20
+
21
+ sig { override.returns(T::Array[String]) }
22
+ def consumed_attributes
23
+ [*super, 'align']
24
+ end
19
25
  end
20
26
  end
21
27
  end
@@ -4,10 +4,7 @@
4
4
  require 'sorbet-runtime'
5
5
 
6
6
  require_relative 'tokens'
7
- require_relative 'inliner/base'
8
- require_relative 'inliner/premailer'
9
- require_relative 'inliner/roadie'
10
- require_relative 'inliner/null'
7
+ require_relative 'inliner'
11
8
 
12
9
  module ActiveMail
13
10
  extend T::Sig
@@ -36,17 +33,17 @@ module ActiveMail
36
33
  block.call(configuration)
37
34
  end
38
35
 
39
- # Parse mode (and the Guard's same decision): a full document carries <html.
36
+ # <html[\s>] so a <htmlfoo> custom tag or "<html" in text never flips the parse mode.
40
37
  sig { params(html: String).returns(T::Boolean) }
41
38
  def self.full_document?(html)
42
- html.match?(/<html/i)
39
+ html.match?(/<html[\s>]/i)
43
40
  end
44
41
 
45
- # The complete SCSS variable bridge — tokens + the container_width layout knob.
46
42
  # Single source for the .scss.erb bridge (Sprockets) and tokens:export (Propshaft).
47
43
  sig { returns(String) }
48
44
  def self.scss_variables
49
- "#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n"
45
+ "#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n" \
46
+ "$am-column-count: #{configuration.column_count} !default;\n"
50
47
  end
51
48
 
52
49
  # Route warnings to the app logger when present (Sentry breadcrumbs, log
@@ -67,9 +64,8 @@ module ActiveMail
67
64
 
68
65
  # Invalid bytes degrade deterministically to U+FFFD, but that silently corrupts
69
66
  # content — surface it per on_parse_error before scrubbing.
70
- sig { params(html_string: String).returns(String) }
71
- def self.scrub_invalid_bytes(html_string)
72
- mode = configuration.on_parse_error
67
+ sig { params(html_string: String, mode: Symbol).returns(String) }
68
+ def self.scrub_invalid_bytes(html_string, mode)
73
69
  message = '[activemail] input had invalid byte sequences; scrubbed to U+FFFD'
74
70
  raise ParseError, message if mode == :raise
75
71
 
@@ -77,20 +73,25 @@ module ActiveMail
77
73
  html_string.scrub
78
74
  end
79
75
 
80
- # Zero or negative dimensions blow up at transform time (Infinity ghost width).
81
- # Reject non-integers rather than silently coercing ("abc"→0, 12.9→12).
76
+ # Rejects non-integers rather than silently coercing ("abc"→0, 12.9→12); zero or
77
+ # negative dimensions blow up at transform time (Infinity ghost width).
82
78
  sig { params(name: Symbol, value: T.untyped).returns(Integer) }
83
- def self.assert_positive_dimension!(name, value)
84
- raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}" unless value.is_a?(Integer) && value.positive?
79
+ def self.positive_integer!(name, value)
80
+ raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
81
+ raise ArgumentError, "#{name} must be positive, got #{value}" unless value.positive?
85
82
 
86
83
  value
87
84
  end
88
85
 
89
- sig { params(name: Symbol, value: T.untyped).returns(Integer) }
90
- def self.positive_integer!(name, value)
91
- raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
86
+ # Author attributes are untrusted strings: base 10 only (Integer() reads "010" as
87
+ # octal and "0x20" as hex), nil when not a positive integer.
88
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
89
+ def self.parse_positive_integer(value)
90
+ string = value.to_s.strip
91
+ return unless string.match?(/\A\d+\z/)
92
92
 
93
- assert_positive_dimension!(name, value)
93
+ int = string.to_i
94
+ int if int.positive?
94
95
  end
95
96
 
96
97
  class Configuration
@@ -98,27 +99,9 @@ module ActiveMail
98
99
 
99
100
  ON_PARSE_ERROR_MODES = T.let(%i[ignore warn raise].freeze, T::Array[Symbol])
100
101
 
101
- INLINERS = T.let(
102
- { premailer: ActiveMail::Inliner::Premailer, roadie: ActiveMail::Inliner::Roadie, null: ActiveMail::Inliner::Null }.freeze,
103
- T::Hash[Symbol, T.class_of(ActiveMail::Inliner::Base)]
104
- )
105
-
106
- InlinerSetting = T.type_alias { T.any(Symbol, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base)) }
107
-
108
- sig { returns(InlinerSetting) }
102
+ sig { returns(ActiveMail::Inliner::Setting) }
109
103
  attr_reader :inliner
110
104
 
111
- # Lets a host already running another inliner (e.g. premailer-rails) opt out.
112
- sig { returns(T::Boolean) }
113
- attr_reader :register_inline_interceptor
114
-
115
- sig { params(value: T.untyped).void }
116
- def register_inline_interceptor=(value)
117
- raise TypeError, 'register_inline_interceptor must be true or false' unless [true, false].include?(value)
118
-
119
- @register_inline_interceptor = value
120
- end
121
-
122
105
  sig { returns(Symbol) }
123
106
  attr_reader :template_engine, :on_parse_error
124
107
 
@@ -146,26 +129,27 @@ module ActiveMail
146
129
  @container_width = T.let(600, Integer)
147
130
  @components = T.let({}, ActiveMail::ComponentMap)
148
131
  @on_parse_error = T.let(:warn, Symbol)
149
- @tokens = T.let(nil, T.nilable(ActiveMail::Tokens))
150
- @inliner = T.let(:premailer, InlinerSetting)
151
- @resolved_inliner = T.let(nil, T.nilable(ActiveMail::Inliner::Base))
152
- @register_inline_interceptor = T.let(true, T::Boolean)
132
+ @inliner = T.let(:premailer, ActiveMail::Inliner::Setting)
153
133
  @blank_link_rel = T.let('noopener', T.nilable(String))
154
134
  end
155
135
 
136
+ # Shared: building the component map per render is pure overhead.
137
+ sig { returns(ActiveMail::Core) }
138
+ def core
139
+ @core ||= T.let(ActiveMail::Core.new(config: self), T.nilable(ActiveMail::Core))
140
+ end
141
+
156
142
  # Validates eagerly (like sibling setters): a typo fails at boot, not silently mid-delivery.
157
- sig { params(value: T.any(Symbol, String, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base))).returns(InlinerSetting) }
143
+ sig { params(value: T.any(String, ActiveMail::Inliner::Setting)).returns(ActiveMail::Inliner::Setting) }
158
144
  def inliner=(value)
159
- resolved = value.is_a?(String) ? value.to_sym : value
160
- resolve_inliner(resolved) # raises on an invalid value, eagerly
161
- @resolved_inliner = nil # a new setting invalidates the memoized instance
162
- @inliner = resolved
145
+ setting = value.is_a?(String) ? value.to_sym : value
146
+ @resolved_inliner = T.let(ActiveMail::Inliner.resolve(setting), T.nilable(ActiveMail::Inliner::Base))
147
+ @inliner = setting
163
148
  end
164
149
 
165
- # Memoized so the lifecycle is consistent (one instance reused, not re-built per call).
166
150
  sig { returns(ActiveMail::Inliner::Base) }
167
151
  def resolved_inliner
168
- @resolved_inliner ||= resolve_inliner(@inliner)
152
+ @resolved_inliner ||= T.let(ActiveMail::Inliner.resolve(@inliner), T.nilable(ActiveMail::Inliner::Base))
169
153
  end
170
154
 
171
155
  sig { params(value: T.untyped).returns(Symbol) }
@@ -186,11 +170,15 @@ module ActiveMail
186
170
  sig { params(value: T.untyped).returns(Integer) }
187
171
  def column_count=(value)
188
172
  @column_count = ActiveMail.positive_integer!(:column_count, value)
173
+ invalidate_core!
174
+ @column_count
189
175
  end
190
176
 
191
177
  sig { params(value: T.untyped).returns(Integer) }
192
178
  def container_width=(value)
193
179
  @container_width = ActiveMail.positive_integer!(:container_width, value)
180
+ invalidate_core!
181
+ @container_width
194
182
  end
195
183
 
196
184
  sig { params(value: T.untyped).returns(T.nilable(String)) }
@@ -209,6 +197,8 @@ module ActiveMail
209
197
  normalized = value.to_hash.transform_keys(&:to_s)
210
198
  normalized.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
211
199
  @components = normalized
200
+ invalidate_core!
201
+ normalized
212
202
  end
213
203
 
214
204
  sig { params(tag: T.any(String, Symbol), component_class: T.class_of(ActiveMail::Components::Base)).void }
@@ -216,18 +206,14 @@ module ActiveMail
216
206
  ActiveMail::Components.validate_component!(tag, component_class)
217
207
 
218
208
  @components = @components.merge(tag.to_s => component_class)
209
+ invalidate_core!
219
210
  end
220
211
 
221
212
  private
222
213
 
223
- # Single source for both eager validation (in the setter) and resolution.
224
- sig { params(value: InlinerSetting).returns(ActiveMail::Inliner::Base) }
225
- def resolve_inliner(value)
226
- return value if value.is_a?(ActiveMail::Inliner::Base)
227
- return value.new if value.is_a?(Class) && value < ActiveMail::Inliner::Base
228
- return T.must(INLINERS[value]).new if value.is_a?(Symbol) && INLINERS.key?(value)
229
-
230
- raise ArgumentError, "unknown inliner #{value.inspect}, expected one of #{INLINERS.keys.inspect}, an Inliner::Base subclass, or an instance"
214
+ sig { void }
215
+ def invalidate_core!
216
+ @core = nil
231
217
  end
232
218
  end
233
219
  end
@@ -0,0 +1,15 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveMail
5
+ class Error < StandardError; end
6
+
7
+ class ParseError < Error; end
8
+
9
+ class StylesheetNotFound < Error; end
10
+
11
+ module Inliner
12
+ # The adapter's original exception is preserved as Exception#cause.
13
+ class Error < ActiveMail::Error; end
14
+ end
15
+ end
@@ -3,12 +3,10 @@
3
3
 
4
4
  require 'sorbet-runtime'
5
5
 
6
+ require_relative '../errors'
7
+
6
8
  module ActiveMail
7
9
  module Inliner
8
- # Raised when an adapter fails; the original (premailer/roadie/nokogiri)
9
- # exception is preserved as Exception#cause with its backtrace intact.
10
- class Error < StandardError; end
11
-
12
10
  # DIP seam: ActiveMail depends on this abstraction, not premailer/roadie.
13
11
  class Base
14
12
  extend T::Sig
@@ -18,12 +16,6 @@ module ActiveMail
18
16
 
19
17
  sig { abstract.params(html: String).returns(String) }
20
18
  def inline(html); end
21
-
22
- # Lets the interceptor skip work without type-checking concrete adapters.
23
- sig { returns(T::Boolean) }
24
- def noop?
25
- false
26
- end
27
19
  end
28
20
  end
29
21
  end
@@ -13,11 +13,6 @@ module ActiveMail
13
13
  def inline(html)
14
14
  html
15
15
  end
16
-
17
- sig { override.returns(T::Boolean) }
18
- def noop?
19
- true
20
- end
21
16
  end
22
17
  end
23
18
  end
@@ -0,0 +1,100 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'digest'
5
+ require 'premailer'
6
+ require 'sorbet-runtime'
7
+
8
+ module ActiveMail
9
+ module Inliner
10
+ class Premailer < Base
11
+ # Premailer re-parses every <style> block with css_parser on each email; the framework
12
+ # CSS never changes between two emails, so its rule sets are parsed once per fingerprint.
13
+ class Document < ::Premailer
14
+ extend T::Sig
15
+
16
+ # Mirrors the parser Premailer builds for these options.
17
+ PARSER_OPTIONS = T.let(
18
+ { absolute_paths: true, import: true, io_exceptions: false, rule_set_exceptions: true, allow_local_network: false }.freeze,
19
+ T::Hash[Symbol, T.untyped]
20
+ )
21
+
22
+ # FIFO-bounded and thread-safe: mailers render from several threads and a dev reload
23
+ # compiles new CSS under a new fingerprint.
24
+ class RuleSetCache
25
+ extend T::Sig
26
+
27
+ LIMIT = 16
28
+
29
+ sig { void }
30
+ def initialize
31
+ @entries = T.let({}, T::Hash[T.untyped, T.untyped])
32
+ @mutex = T.let(Mutex.new, Mutex)
33
+ end
34
+
35
+ sig { params(key: T.untyped, block: T.proc.returns(T.untyped)).returns(T.untyped) }
36
+ def fetch(key, &block)
37
+ @mutex.synchronize { @entries[key] } || store(key, block.call)
38
+ end
39
+
40
+ sig { void }
41
+ def clear
42
+ @mutex.synchronize { @entries.clear }
43
+ end
44
+
45
+ sig { returns(Integer) }
46
+ def size
47
+ @mutex.synchronize { @entries.size }
48
+ end
49
+
50
+ private
51
+
52
+ sig { params(key: T.untyped, value: T.untyped).returns(T.untyped) }
53
+ def store(key, value)
54
+ @mutex.synchronize do
55
+ @entries[key] = value
56
+ @entries.shift while @entries.size > LIMIT
57
+ end
58
+ value
59
+ end
60
+ end
61
+
62
+ class CachedParser < ::CssParser::Parser
63
+ extend T::Sig
64
+
65
+ sig { params(block: String, options: T::Hash[Symbol, T.untyped]).void }
66
+ def add_block!(block, options = {})
67
+ Document.rule_sets.fetch([Digest::SHA256.hexdigest(block), options]) { parse(block, options) }.each do |rule_set, media_types|
68
+ add_rule_set!(rule_set, media_types)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ sig { params(block: String, options: T::Hash[Symbol, T.untyped]).returns(T::Array[T.untyped]) }
75
+ def parse(block, options)
76
+ parser = ::CssParser::Parser.new(PARSER_OPTIONS)
77
+ parser.add_block!(block, options)
78
+ rule_sets = T.let([], T::Array[T.untyped])
79
+ parser.each_rule_set { |rule_set, media_types| rule_sets << [rule_set, media_types] }
80
+ rule_sets
81
+ end
82
+ end
83
+
84
+ sig { returns(RuleSetCache) }
85
+ def self.rule_sets
86
+ @rule_sets ||= T.let(RuleSetCache.new, T.nilable(RuleSetCache))
87
+ end
88
+
89
+ protected
90
+
91
+ # Premailer exposes no hook for its parser: swap it before the <style> blocks are read.
92
+ sig { void }
93
+ def load_css_from_html!
94
+ T.unsafe(self).instance_variable_set(:@css_parser, CachedParser.new(PARSER_OPTIONS))
95
+ super
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end