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
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ # Renders a bare <tr> (mirrors inky.js).
9
+ class Inky < Base
10
+ extend T::Sig
11
+
12
+ sig { override.params(_node: Nokogiri::XML::Node, inner: String).returns(String) }
13
+ def transform(_node, inner)
14
+ %(<tr>#{inner}</tr>)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class Menu < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ attributes = combine_attributes(node, 'menu')
14
+ [
15
+ %(<table #{attributes} #{TABLE_RESET}#{style_attribute(node)}><tbody><tr><td>),
16
+ %(<table #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>),
17
+ '</td></tr></tbody></table>'
18
+ ].join
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class MenuItem < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ attributes = combine_attributes(node, 'menu-item')
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'))}"#{target_attribute(node)}>#{inner}</a>) : inner
16
+ th = ::ActiveMail::Core::INTERIM_TH_TAG
17
+ %(<#{th} #{attributes}#{style_attribute(node)}>#{content}</#{th}>)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class Row < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
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>)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,45 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class Spacer < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
12
+ def transform(node, _inner)
13
+ classes = combine_classes(node, 'spacer')
14
+ size_sm = node.attr('size-sm')
15
+ size_lg = node.attr('size-lg')
16
+
17
+ return build_table(node, classes, nil, size_for(node.attr('size'))) unless size_sm || size_lg
18
+
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
23
+ end
24
+
25
+ private
26
+
27
+ sig { params(value: T.untyped).returns(Integer) }
28
+ def size_for(value)
29
+ positive_int(value) || 16
30
+ end
31
+
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
35
+ # mso-line-height-rule:exactly keeps Outlook from inflating the spacer.
36
+ style = "font-size:#{size}px;line-height:#{size}px;mso-line-height-rule:exactly;"
37
+ [
38
+ %(<table class="#{css_class}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>),
39
+ %(<td height="#{size}" style="#{style}">&nbsp;</td>),
40
+ '</tr></tbody></table>'
41
+ ].join
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,21 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Components
8
+ class Wrapper < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
12
+ def transform(node, inner)
13
+ attributes = combine_attributes(node, 'wrapper')
14
+ [
15
+ %(<table #{attributes} #{TABLE_RESET} align="center"#{style_attribute(node, 'width:100%;')}><tbody><tr>),
16
+ %(<td class="wrapper-inner">#{inner}</td></tr></tbody></table>)
17
+ ].join
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,229 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ require_relative 'tokens'
7
+ require_relative 'inliner/base'
8
+ require_relative 'inliner/premailer'
9
+ require_relative 'inliner/roadie'
10
+ require_relative 'inliner/null'
11
+
12
+ module ActiveMail
13
+ extend T::Sig
14
+
15
+ ComponentMap = T.type_alias { T::Hash[String, T.class_of(ActiveMail::Components::Base)] }
16
+
17
+ sig { returns(ActiveMail::Configuration) }
18
+ def self.configuration
19
+ @configuration ||= T.let(Configuration.new, T.nilable(ActiveMail::Configuration))
20
+ end
21
+
22
+ sig { returns(ActiveMail::Tokens) }
23
+ def self.tokens
24
+ configuration.tokens
25
+ end
26
+
27
+ sig { params(config: T.untyped).returns(ActiveMail::Configuration) }
28
+ def self.configuration=(config)
29
+ raise TypeError, 'Not an ActiveMail::Configuration' unless config.is_a?(Configuration)
30
+
31
+ @configuration = config
32
+ end
33
+
34
+ sig { params(block: T.proc.params(config: ActiveMail::Configuration).void).void }
35
+ def self.configure(&block)
36
+ block.call(configuration)
37
+ end
38
+
39
+ # Parse mode (and the Guard's same decision): a full document carries <html.
40
+ sig { params(html: String).returns(T::Boolean) }
41
+ def self.full_document?(html)
42
+ html.match?(/<html/i)
43
+ end
44
+
45
+ # The complete SCSS variable bridge — tokens + the container_width layout knob.
46
+ # Single source for the .scss.erb bridge (Sprockets) and tokens:export (Propshaft).
47
+ sig { returns(String) }
48
+ def self.scss_variables
49
+ "#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n"
50
+ end
51
+
52
+ # Route warnings to the app logger when present (Sentry breadcrumbs, log
53
+ # aggregation), else $stderr — which is often dropped in production.
54
+ sig { params(message: String).void }
55
+ def self.log_warning(message)
56
+ logger = rails_logger
57
+ logger ? logger.warn(message) : Kernel.warn(message)
58
+ end
59
+
60
+ sig { returns(T.untyped) }
61
+ def self.rails_logger
62
+ return unless Object.const_defined?(:Rails)
63
+
64
+ rails = Object.const_get(:Rails)
65
+ rails.respond_to?(:logger) ? rails.logger : nil
66
+ end
67
+
68
+ # Invalid bytes degrade deterministically to U+FFFD, but that silently corrupts
69
+ # 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
73
+ message = '[activemail] input had invalid byte sequences; scrubbed to U+FFFD'
74
+ raise ParseError, message if mode == :raise
75
+
76
+ log_warning(message) unless mode == :ignore
77
+ html_string.scrub
78
+ end
79
+
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).
82
+ 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?
85
+
86
+ value
87
+ end
88
+
89
+ class Configuration
90
+ extend T::Sig
91
+
92
+ ON_PARSE_ERROR_MODES = T.let(%i[ignore warn raise].freeze, T::Array[Symbol])
93
+
94
+ INLINERS = T.let(
95
+ { premailer: ActiveMail::Inliner::Premailer, roadie: ActiveMail::Inliner::Roadie, null: ActiveMail::Inliner::Null }.freeze,
96
+ T::Hash[Symbol, T.class_of(ActiveMail::Inliner::Base)]
97
+ )
98
+
99
+ InlinerSetting = T.type_alias { T.any(Symbol, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base)) }
100
+
101
+ sig { returns(InlinerSetting) }
102
+ attr_reader :inliner
103
+
104
+ # Lets a host already running another inliner (e.g. premailer-rails) opt out.
105
+ sig { returns(T::Boolean) }
106
+ attr_reader :register_inline_interceptor
107
+
108
+ sig { params(value: T.untyped).void }
109
+ def register_inline_interceptor=(value)
110
+ raise TypeError, 'register_inline_interceptor must be true or false' unless [true, false].include?(value)
111
+
112
+ @register_inline_interceptor = value
113
+ end
114
+
115
+ sig { returns(Symbol) }
116
+ attr_reader :template_engine
117
+
118
+ sig { returns(Symbol) }
119
+ attr_reader :on_parse_error
120
+
121
+ sig { returns(Integer) }
122
+ attr_reader :column_count
123
+
124
+ sig { returns(Integer) }
125
+ attr_reader :container_width
126
+
127
+ # Mutating the returned hash would bypass validate_component!.
128
+ sig { returns(ActiveMail::ComponentMap) }
129
+ def components
130
+ @components.dup.freeze
131
+ end
132
+
133
+ sig { returns(ActiveMail::Tokens) }
134
+ def tokens
135
+ @tokens ||= T.let(ActiveMail::Tokens.new, T.nilable(ActiveMail::Tokens))
136
+ end
137
+
138
+ sig { void }
139
+ def initialize
140
+ @template_engine = T.let(:erb, Symbol)
141
+ @column_count = T.let(12, Integer)
142
+ @container_width = T.let(600, Integer)
143
+ @components = T.let({}, ActiveMail::ComponentMap)
144
+ @on_parse_error = T.let(:warn, Symbol)
145
+ @tokens = T.let(nil, T.nilable(ActiveMail::Tokens))
146
+ @inliner = T.let(:premailer, InlinerSetting)
147
+ @resolved_inliner = T.let(nil, T.nilable(ActiveMail::Inliner::Base))
148
+ @register_inline_interceptor = T.let(true, T::Boolean)
149
+ end
150
+
151
+ # Validates eagerly (like sibling setters): a typo fails at boot, not silently mid-delivery.
152
+ sig { params(value: T.any(Symbol, String, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base))).returns(InlinerSetting) }
153
+ def inliner=(value)
154
+ resolved = value.is_a?(String) ? value.to_sym : value
155
+ resolve_inliner(resolved) # raises on an invalid value, eagerly
156
+ @resolved_inliner = nil # a new setting invalidates the memoized instance
157
+ @inliner = resolved
158
+ end
159
+
160
+ # Memoized so the lifecycle is consistent (one instance reused, not re-built per call).
161
+ sig { returns(ActiveMail::Inliner::Base) }
162
+ def resolved_inliner
163
+ @resolved_inliner ||= resolve_inliner(@inliner)
164
+ end
165
+
166
+ sig { params(value: T.untyped).returns(Symbol) }
167
+ def on_parse_error=(value)
168
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
169
+ raise ArgumentError, "on_parse_error must be one of #{ON_PARSE_ERROR_MODES.inspect}" unless ON_PARSE_ERROR_MODES.include?(mode)
170
+
171
+ @on_parse_error = mode
172
+ end
173
+
174
+ sig { params(value: T.untyped).returns(Symbol) }
175
+ def template_engine=(value)
176
+ raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_sym'" unless value.respond_to?(:to_sym)
177
+
178
+ @template_engine = value.to_sym
179
+ end
180
+
181
+ sig { params(value: T.untyped).returns(Integer) }
182
+ def column_count=(value)
183
+ @column_count = positive_integer!(:column_count, value)
184
+ end
185
+
186
+ sig { params(value: T.untyped).returns(Integer) }
187
+ def container_width=(value)
188
+ @container_width = positive_integer!(:container_width, value)
189
+ end
190
+
191
+ sig { params(value: T.untyped).returns(ActiveMail::ComponentMap) }
192
+ def components=(value)
193
+ raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_hash'" unless value.respond_to?(:to_hash)
194
+
195
+ # Lookup is by node name (String); a Symbol key would never match.
196
+ normalized = value.to_hash.transform_keys(&:to_s)
197
+ normalized.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
198
+ @components = normalized
199
+ end
200
+
201
+ sig { params(tag: T.any(String, Symbol), component_class: T.class_of(ActiveMail::Components::Base)).void }
202
+ def register_component(tag, component_class)
203
+ ActiveMail::Components.validate_component!(tag, component_class)
204
+
205
+ @components = @components.merge(tag.to_s => component_class)
206
+ end
207
+
208
+ private
209
+
210
+ # Single source for both eager validation (in the setter) and resolution.
211
+ sig { params(value: InlinerSetting).returns(ActiveMail::Inliner::Base) }
212
+ def resolve_inliner(value)
213
+ return value if value.is_a?(ActiveMail::Inliner::Base)
214
+ return value.new if value.is_a?(Class) && value < ActiveMail::Inliner::Base
215
+ return T.must(INLINERS[value]).new if value.is_a?(Symbol) && INLINERS.key?(value)
216
+
217
+ raise ArgumentError, "unknown inliner #{value.inspect}, expected one of #{INLINERS.keys.inspect}, an Inliner::Base subclass, or an instance"
218
+ end
219
+
220
+ # Integer-only (no to_int): a Float would otherwise be silently truncated
221
+ # (12.9 -> 12), contradicting assert_positive_dimension!'s invariant.
222
+ sig { params(name: Symbol, value: T.untyped).returns(Integer) }
223
+ def positive_integer!(name, value)
224
+ raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
225
+
226
+ ActiveMail.assert_positive_dimension!(name, value)
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ActiveMail
7
+ 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
+ # DIP seam: ActiveMail depends on this abstraction, not premailer/roadie.
13
+ class Base
14
+ extend T::Sig
15
+ extend T::Helpers
16
+
17
+ abstract!
18
+
19
+ sig { abstract.params(html: String).returns(String) }
20
+ 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
+ end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ActiveMail
7
+ module Inliner
8
+ class Interceptor
9
+ class << self
10
+ extend T::Sig
11
+
12
+ sig { params(message: T.untyped).void }
13
+ def delivering_email(message)
14
+ config = ActiveMail.configuration
15
+ # Runtime check: an engine initializer runs before the host's, so a
16
+ # boot-time flag read would always see the default.
17
+ return unless config.register_inline_interceptor
18
+
19
+ inliner = config.resolved_inliner
20
+ return if inliner.noop?
21
+
22
+ html_parts(message).each do |part|
23
+ part.body = inliner.inline(part.body.to_s)
24
+ rescue StandardError => e
25
+ # Surface which inliner failed; do not swallow (a silently non-inlined
26
+ # mail is worse). Ruby sets e as #cause, preserving the original backtrace.
27
+ raise ActiveMail::Inliner::Error, "[#{inliner.class}] CSS inlining failed: #{e.message}"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # The html part(s) to inline: a single text/html message, or every text/html
34
+ # part of a multipart message. Attachments are never inlined.
35
+ sig { params(message: T.untyped).returns(T::Array[T.untyped]) }
36
+ def html_parts(message)
37
+ return message.all_parts.select { |part| html_body_part?(part) } if message.multipart?
38
+
39
+ html_body_part?(message) ? [message] : []
40
+ end
41
+
42
+ sig { params(part: T.untyped).returns(T::Boolean) }
43
+ def html_body_part?(part)
44
+ return false unless part.respond_to?(:mime_type) && part.mime_type == 'text/html'
45
+
46
+ !(part.respond_to?(:attachment?) && part.attachment?)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Inliner
8
+ # Opt-out / test adapter: returns HTML untouched.
9
+ class Null < Base
10
+ extend T::Sig
11
+
12
+ sig { override.params(html: String).returns(String) }
13
+ def inline(html)
14
+ html
15
+ end
16
+
17
+ sig { override.returns(T::Boolean) }
18
+ def noop?
19
+ true
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Inliner
8
+ class Premailer < Base
9
+ extend T::Sig
10
+
11
+ sig { override.params(html: String).returns(String) }
12
+ def inline(html)
13
+ # premailer is a hard runtime dependency (the default inliner), so require
14
+ # can't fail — unlike the optional Roadie adapter, which guards LoadError.
15
+ require 'premailer'
16
+ # warn_level NONE: premailer's un-inlinable-CSS warnings are noise at delivery
17
+ # time; the quality layer is where coverage gaps should surface.
18
+ ::Premailer.new(html, with_html_string: true, warn_level: ::Premailer::Warnings::NONE).to_inline_css
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative 'base'
5
+
6
+ module ActiveMail
7
+ module Inliner
8
+ # Optional adapter — roadie is not a runtime dependency of the gem.
9
+ class Roadie < Base
10
+ extend T::Sig
11
+
12
+ sig { override.params(html: String).returns(String) }
13
+ def inline(html)
14
+ require_roadie!
15
+ ::Roadie::Document.new(html).transform
16
+ end
17
+
18
+ private
19
+
20
+ sig { void }
21
+ def require_roadie!
22
+ require 'roadie'
23
+ rescue LoadError
24
+ # Unify on Inliner::Error (LoadError is not a StandardError, so the
25
+ # interceptor's rescue would otherwise miss it); the LoadError is #cause.
26
+ raise ActiveMail::Inliner::Error, "ActiveMail::Inliner::Roadie requires the 'roadie' gem. Add it to your Gemfile."
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveMail
5
+ # libxml2 XML_HTML_UNKNOWN_TAG: emitted for every non-HTML4 tag (HTML5/custom
6
+ # tags), not actual malformedness. Shared by the engine and the quality layer.
7
+ LIBXML_UNKNOWN_TAG_CODE = 801
8
+ end
@@ -0,0 +1,47 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ require_relative 'libxml'
7
+
8
+ module ActiveMail
9
+ # Surfaces libxml2 recover-mode repairs that would otherwise silently change
10
+ # the rendered email, honoring ActiveMail.configuration.on_parse_error.
11
+ class ParseErrorReporter
12
+ extend T::Sig
13
+
14
+ sig { params(known_tags: T::Array[String]).void }
15
+ def initialize(known_tags)
16
+ # dup.freeze: the caller's array stays decoupled and cannot be mutated under us.
17
+ @known_tags = T.let(known_tags.dup.freeze, T::Array[String])
18
+ end
19
+
20
+ sig { params(errors: T::Array[Nokogiri::XML::SyntaxError]).void }
21
+ def call(errors)
22
+ mode = ::ActiveMail.configuration.on_parse_error
23
+ return if mode == :ignore
24
+
25
+ relevant = errors.reject { |error| known_tag_error?(error) }
26
+ return if relevant.empty?
27
+
28
+ messages = relevant.map { |error| error.message.to_s.strip }.join('; ')
29
+ raise ActiveMail::ParseError, messages if mode == :raise
30
+
31
+ ::ActiveMail.log_warning("[activemail] HTML parse issues: #{messages}")
32
+ end
33
+
34
+ private
35
+
36
+ sig { params(error: Nokogiri::XML::SyntaxError).returns(T::Boolean) }
37
+ def known_tag_error?(error)
38
+ return false unless error.code == LIBXML_UNKNOWN_TAG_CODE
39
+
40
+ # Fragile by necessity: the 801 error does not carry the tag name, only
41
+ # the libxml2 message text does. Locked by test against the pinned
42
+ # Nokogiri; revisit on bump if that test breaks.
43
+ tag = error.message.to_s[/Tag (\S+) invalid/, 1]
44
+ !tag.nil? && @known_tags.include?(tag)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,56 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ require_relative 'guard'
7
+
8
+ module ActiveMail
9
+ module Quality
10
+ class Configuration
11
+ extend T::Sig
12
+
13
+ sig { returns(Guard) }
14
+ attr_reader :guard
15
+
16
+ sig { params(value: T.untyped).void }
17
+ def guard=(value)
18
+ raise TypeError, "guard must be an ActiveMail::Quality::Guard, got #{value.class}" unless value.is_a?(Guard)
19
+
20
+ @guard = value
21
+ end
22
+
23
+ sig { returns(String) }
24
+ attr_reader :output_dir
25
+
26
+ sig { params(value: String).void }
27
+ def output_dir=(value)
28
+ raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
29
+
30
+ @output_dir = value
31
+ end
32
+
33
+ # Preview keys ("preview_name#email") that MUST render and pass the guard;
34
+ # a failure on any of these aborts the rake task. Other previews are only
35
+ # reported.
36
+ sig { returns(T::Array[String]) }
37
+ def required_previews
38
+ @required_previews.dup.freeze
39
+ end
40
+
41
+ sig { params(value: T.untyped).returns(T::Array[String]) }
42
+ def required_previews=(value)
43
+ raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_a'" unless value.respond_to?(:to_a)
44
+
45
+ @required_previews = value.to_a.map(&:to_s)
46
+ end
47
+
48
+ sig { void }
49
+ def initialize
50
+ @guard = T.let(Guard.new, Guard)
51
+ @output_dir = T.let('tmp/active_mail_previews', String)
52
+ @required_previews = T.let([], T::Array[String])
53
+ end
54
+ end
55
+ end
56
+ end