activemail 1.2.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -2
- data/README.md +105 -47
- data/UPGRADING.md +112 -0
- data/app/assets/stylesheets/activemail/_components.scss +33 -42
- data/app/assets/stylesheets/activemail/_dark.scss +31 -20
- data/app/assets/stylesheets/activemail/_grid.scss +2 -0
- data/app/assets/stylesheets/activemail/_settings.scss +22 -10
- data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
- data/app/assets/stylesheets/activemail/activemail.scss +6 -5
- data/app/helpers/active_mail/styles_helper.rb +14 -15
- data/app/views/layouts/activemail/mailer.html.inky-erb +0 -5
- data/lib/activemail/asset_pipeline/base.rb +47 -0
- data/lib/activemail/asset_pipeline/null.rb +19 -0
- data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
- data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
- data/lib/activemail/asset_pipeline.rb +35 -0
- data/lib/activemail/components/base.rb +35 -39
- data/lib/activemail/components/block_grid.rb +11 -4
- data/lib/activemail/components/button.rb +5 -28
- data/lib/activemail/components/button_renderer.rb +65 -0
- data/lib/activemail/components/callout.rb +4 -5
- data/lib/activemail/components/center.rb +5 -7
- data/lib/activemail/components/columns.rb +26 -7
- data/lib/activemail/components/container.rb +11 -4
- data/lib/activemail/components/cta.rb +9 -33
- data/lib/activemail/components/h_line.rb +3 -5
- data/lib/activemail/components/info_box.rb +6 -12
- data/lib/activemail/components/menu.rb +3 -4
- data/lib/activemail/components/menu_item.rb +10 -3
- data/lib/activemail/components/row.rb +3 -4
- data/lib/activemail/components/spacer.rb +16 -12
- data/lib/activemail/components/table_component.rb +23 -0
- data/lib/activemail/components/wrapper.rb +10 -4
- data/lib/activemail/configuration.rb +43 -57
- data/lib/activemail/errors.rb +15 -0
- data/lib/activemail/inliner/base.rb +2 -10
- data/lib/activemail/inliner/null.rb +0 -5
- data/lib/activemail/inliner/roadie.rb +1 -1
- data/lib/activemail/inliner.rb +33 -0
- data/lib/activemail/parse_error_reporter.rb +5 -4
- data/lib/activemail/pipeline.rb +26 -0
- data/lib/activemail/quality/builtin_rules.rb +154 -0
- data/lib/activemail/quality/configuration.rb +83 -3
- data/lib/activemail/quality/guard.rb +45 -87
- data/lib/activemail/quality/minitest.rb +41 -25
- data/lib/activemail/quality/output_dir.rb +52 -0
- data/lib/activemail/quality/preview_renderer.rb +46 -33
- data/lib/activemail/quality/render_all.rb +96 -40
- data/lib/activemail/quality/rspec.rb +4 -8
- data/lib/activemail/quality/rule.rb +47 -0
- data/lib/activemail/rails/engine.rb +13 -13
- data/lib/activemail/rails/rendering.rb +51 -0
- data/lib/activemail/rails/template_handler.rb +3 -1
- data/lib/activemail/tokens/button_style.rb +1 -2
- data/lib/activemail/tokens/defaults.rb +77 -0
- data/lib/activemail/tokens/scss_serializer.rb +4 -4
- data/lib/activemail/tokens.rb +19 -58
- data/lib/activemail/version.rb +1 -1
- data/lib/activemail.rb +89 -61
- data/lib/generators/activemail/templates/component.rb.tt +2 -3
- data/lib/generators/activemail/templates/initializer.rb +0 -3
- data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
- data/lib/tasks/activemail.rake +4 -5
- metadata +38 -7
- data/lib/activemail/components/inky.rb +0 -18
- data/lib/activemail/inliner/interceptor.rb +0 -51
- data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
require 'sorbet-runtime'
|
|
5
5
|
|
|
6
6
|
require_relative 'tokens'
|
|
7
|
-
require_relative 'inliner
|
|
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
|
-
#
|
|
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
|
-
#
|
|
81
|
-
#
|
|
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.
|
|
84
|
-
raise
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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(
|
|
143
|
+
sig { params(value: T.any(String, ActiveMail::Inliner::Setting)).returns(ActiveMail::Inliner::Setting) }
|
|
158
144
|
def inliner=(value)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
@
|
|
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 ||=
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
@@ -22,7 +22,7 @@ module ActiveMail
|
|
|
22
22
|
require 'roadie'
|
|
23
23
|
rescue LoadError
|
|
24
24
|
# Unify on Inliner::Error (LoadError is not a StandardError, so the
|
|
25
|
-
#
|
|
25
|
+
# Pipeline's rescue would otherwise miss it); the LoadError is #cause.
|
|
26
26
|
raise ActiveMail::Inliner::Error, "ActiveMail::Inliner::Roadie requires the 'roadie' gem. Add it to your Gemfile."
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'inliner/base'
|
|
7
|
+
require_relative 'inliner/premailer'
|
|
8
|
+
require_relative 'inliner/roadie'
|
|
9
|
+
require_relative 'inliner/null'
|
|
10
|
+
|
|
11
|
+
module ActiveMail
|
|
12
|
+
module Inliner
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
ADAPTERS = T.let(
|
|
16
|
+
{ premailer: Premailer, roadie: Roadie, null: Null }.freeze,
|
|
17
|
+
T::Hash[Symbol, T.class_of(Base)]
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
Setting = T.type_alias { T.any(Symbol, Base, T.class_of(Base)) }
|
|
21
|
+
|
|
22
|
+
sig { params(setting: Setting).returns(Base) }
|
|
23
|
+
def self.resolve(setting)
|
|
24
|
+
return setting if setting.is_a?(Base)
|
|
25
|
+
return setting.new if setting.is_a?(Class) && setting < Base
|
|
26
|
+
|
|
27
|
+
adapter = ADAPTERS[setting] if setting.is_a?(Symbol)
|
|
28
|
+
return adapter.new if adapter
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "unknown inliner #{setting.inspect}, expected one of #{ADAPTERS.keys.inspect}, an Inliner::Base subclass, or an instance"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -7,19 +7,20 @@ require_relative 'libxml'
|
|
|
7
7
|
|
|
8
8
|
module ActiveMail
|
|
9
9
|
# Surfaces libxml2 recover-mode repairs that would otherwise silently change
|
|
10
|
-
# the rendered email, honoring
|
|
10
|
+
# the rendered email, honoring the config's on_parse_error.
|
|
11
11
|
class ParseErrorReporter
|
|
12
12
|
extend T::Sig
|
|
13
13
|
|
|
14
|
-
sig { params(known_tags: T::Array[String]).void }
|
|
15
|
-
def initialize(known_tags)
|
|
14
|
+
sig { params(known_tags: T::Array[String], config: ActiveMail::Configuration).void }
|
|
15
|
+
def initialize(known_tags, config)
|
|
16
|
+
@config = config
|
|
16
17
|
# dup.freeze: the caller's array stays decoupled and cannot be mutated under us.
|
|
17
18
|
@known_tags = T.let(known_tags.dup.freeze, T::Array[String])
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
sig { params(errors: T::Array[Nokogiri::XML::SyntaxError]).void }
|
|
21
22
|
def call(errors)
|
|
22
|
-
mode =
|
|
23
|
+
mode = @config.on_parse_error
|
|
23
24
|
return if mode == :ignore
|
|
24
25
|
|
|
25
26
|
relevant = errors.reject { |error| known_tag_error?(error) }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Pipeline
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(html: String, config: ActiveMail::Configuration).returns(String) }
|
|
11
|
+
def self.call(html, config: ActiveMail.configuration)
|
|
12
|
+
inline(config.resolved_inliner, config.core.transpile(html))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
sig { params(inliner: ActiveMail::Inliner::Base, html: String).returns(String) }
|
|
16
|
+
def self.inline(inliner, html)
|
|
17
|
+
inliner.inline(html)
|
|
18
|
+
rescue ActiveMail::Inliner::Error
|
|
19
|
+
raise
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
# A silently non-inlined mail is worse than a failed one.
|
|
22
|
+
raise ActiveMail::Inliner::Error, "[#{inliner.class}] CSS inlining failed: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
private_class_method :inline
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
require_relative '../libxml'
|
|
8
|
+
require_relative 'preview_renderer'
|
|
9
|
+
require_relative 'rule'
|
|
10
|
+
|
|
11
|
+
module ActiveMail
|
|
12
|
+
module Quality
|
|
13
|
+
class BuiltinRules
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
NAMES = T.let(
|
|
17
|
+
%i[
|
|
18
|
+
max_bytes parse_error table_role img_alt lang min_full_doc_bytes translation_missing single_root
|
|
19
|
+
translation_missing_text html_part_present text_part_present text_part_plain
|
|
20
|
+
].freeze,
|
|
21
|
+
T::Array[Symbol]
|
|
22
|
+
)
|
|
23
|
+
MARKUP_IN_TEXT = %r{<(?:table|div|span|p|br|a)[\s>/]}i
|
|
24
|
+
MISSING_TRANSLATION = /translation[ _]missing/i
|
|
25
|
+
ROOTS = T.let({ html: /<html[\s>]/i, body: /<body[\s>]/i }.freeze, T::Hash[Symbol, Regexp])
|
|
26
|
+
|
|
27
|
+
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer).void }
|
|
28
|
+
def initialize(max_bytes:, min_full_doc_bytes:)
|
|
29
|
+
# A non-positive threshold would silently disable (or invert) a check.
|
|
30
|
+
@max_bytes = T.let(::ActiveMail.positive_integer!(:max_bytes, max_bytes), Integer)
|
|
31
|
+
@min_full_doc_bytes = T.let(::ActiveMail.positive_integer!(:min_full_doc_bytes, min_full_doc_bytes), Integer)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { returns(T::Array[Rule]) }
|
|
35
|
+
def rules
|
|
36
|
+
structure_rules + content_rules + message_rules
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
sig { returns(T::Array[Rule]) }
|
|
42
|
+
def structure_rules
|
|
43
|
+
[
|
|
44
|
+
Rule.html(:max_bytes) { |_doc, html| size_message(html) },
|
|
45
|
+
# The Core surfaces libxml2 repairs on input; the validation layer must not
|
|
46
|
+
# silently bless malformed *output* (mismatched tags, bad entities) before send.
|
|
47
|
+
Rule.html(:parse_error) { |doc, _html| parse_error_message(doc) },
|
|
48
|
+
Rule.html(:table_role) { |doc, _html| table_role_message(doc) },
|
|
49
|
+
Rule.html(:img_alt) { |doc, _html| img_alt_message(doc) }
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { returns(T::Array[Rule]) }
|
|
54
|
+
def content_rules
|
|
55
|
+
[
|
|
56
|
+
Rule.html(:translation_missing) { |_doc, html| translation_missing_message(html) },
|
|
57
|
+
Rule.html(:single_root) { |_doc, html| single_root_messages(html) },
|
|
58
|
+
Rule.html(:min_full_doc_bytes) { |_doc, html| min_full_doc_message(html) },
|
|
59
|
+
Rule.html(:lang) { |doc, html| lang_message(doc, html) }
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { returns(T::Array[Rule]) }
|
|
64
|
+
def message_rules
|
|
65
|
+
[
|
|
66
|
+
Rule.message(:translation_missing_text) { |mail| translation_missing_text_message(mail) },
|
|
67
|
+
Rule.message(:html_part_present) { |mail| html_part_present_message(mail) },
|
|
68
|
+
Rule.message(:text_part_present) { |mail| text_part_present_message(mail) },
|
|
69
|
+
Rule.message(:text_part_plain) { |mail| text_part_plain_message(mail) }
|
|
70
|
+
]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
74
|
+
def size_message(html)
|
|
75
|
+
return if html.bytesize <= @max_bytes
|
|
76
|
+
|
|
77
|
+
"HTML is #{html.bytesize} bytes, exceeds #{@max_bytes} (Gmail clipping)"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
81
|
+
def parse_error_message(doc)
|
|
82
|
+
errors = doc.errors.reject { |e| e.code == LIBXML_UNKNOWN_TAG_CODE }
|
|
83
|
+
return if errors.empty?
|
|
84
|
+
|
|
85
|
+
"malformed HTML: #{errors.first(3).map { |e| e.message.to_s.strip }.join('; ')}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
89
|
+
def table_role_message(doc)
|
|
90
|
+
offenders = doc.css('table').count { |table| table['role'] != 'presentation' }
|
|
91
|
+
%(#{offenders} <table> missing role="presentation") unless offenders.zero?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
95
|
+
def img_alt_message(doc)
|
|
96
|
+
offenders = doc.css('img').count { |img| !img.key?('alt') }
|
|
97
|
+
"#{offenders} <img> missing an alt attribute" unless offenders.zero?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
101
|
+
def translation_missing_message(html)
|
|
102
|
+
'rendered HTML contains a missing i18n translation' if html.match?(MISSING_TRANSLATION)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Counted on the raw string: libxml2 silently merges duplicate roots.
|
|
106
|
+
sig { params(html: String).returns(T::Array[String]) }
|
|
107
|
+
def single_root_messages(html)
|
|
108
|
+
ROOTS.filter_map do |tag, pattern|
|
|
109
|
+
count = html.scan(pattern).size
|
|
110
|
+
"#{count} <#{tag}> roots, expected at most one" if count > 1
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
115
|
+
def min_full_doc_message(html)
|
|
116
|
+
return unless ::ActiveMail.full_document?(html) && html.bytesize < @min_full_doc_bytes
|
|
117
|
+
|
|
118
|
+
"full document is only #{html.bytesize} bytes (under #{@min_full_doc_bytes})"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
|
|
122
|
+
def lang_message(doc, html)
|
|
123
|
+
return unless ::ActiveMail.full_document?(html)
|
|
124
|
+
|
|
125
|
+
html_tag = doc.at_css('html')
|
|
126
|
+
lang = html_tag && html_tag['lang']
|
|
127
|
+
'<html> must declare a non-blank lang attribute' if lang.nil? || lang.strip.empty?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
131
|
+
def translation_missing_text_message(mail)
|
|
132
|
+
text = "#{mail.subject}\n#{PreviewRenderer.text_body(mail)}"
|
|
133
|
+
'subject or text part contains a missing i18n translation' if text.match?(MISSING_TRANSLATION)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
137
|
+
def html_part_present_message(mail)
|
|
138
|
+
'message has no HTML part' if PreviewRenderer.html_body(mail).empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
142
|
+
def text_part_present_message(mail)
|
|
143
|
+
return if mail.multipart? && !PreviewRenderer.text_body(mail).strip.empty?
|
|
144
|
+
|
|
145
|
+
'message must be multipart with a non-empty text/plain part'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
149
|
+
def text_part_plain_message(mail)
|
|
150
|
+
'text/plain part contains HTML markup' if PreviewRenderer.text_body(mail).match?(MARKUP_IN_TEXT)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require 'i18n'
|
|
4
5
|
require 'sorbet-runtime'
|
|
5
6
|
|
|
6
7
|
require_relative 'guard'
|
|
8
|
+
require_relative 'output_dir'
|
|
7
9
|
|
|
8
10
|
module ActiveMail
|
|
9
11
|
module Quality
|
|
10
12
|
class Configuration
|
|
11
13
|
extend T::Sig
|
|
12
14
|
|
|
15
|
+
FORMATS = T.let(%i[html text].freeze, T::Array[Symbol])
|
|
16
|
+
DEFAULT_LOCALES = T.let(->(_preview) { [I18n.default_locale] }, Proc)
|
|
17
|
+
|
|
13
18
|
sig { returns(Guard) }
|
|
14
19
|
attr_reader :guard
|
|
15
20
|
|
|
@@ -25,9 +30,7 @@ module ActiveMail
|
|
|
25
30
|
|
|
26
31
|
sig { params(value: String).void }
|
|
27
32
|
def output_dir=(value)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@output_dir = value
|
|
33
|
+
@output_dir = OutputDir.validate!(value)
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
# Preview keys ("preview_name#email") that MUST render and pass the guard;
|
|
@@ -45,11 +48,88 @@ module ActiveMail
|
|
|
45
48
|
@required_previews = value.to_a.map(&:to_s)
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
sig { returns(T.any(T::Array[Symbol], Proc)) }
|
|
52
|
+
attr_reader :locales
|
|
53
|
+
|
|
54
|
+
sig { params(value: T.untyped).void }
|
|
55
|
+
def locales=(value)
|
|
56
|
+
@locales = value.is_a?(Proc) ? value : normalize_locales(value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { params(preview: T.untyped).returns(T::Array[Symbol]) }
|
|
60
|
+
def locales_for(preview)
|
|
61
|
+
value = @locales
|
|
62
|
+
value.is_a?(Proc) ? normalize_locales(value.call(preview)) : value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { returns(T::Array[Symbol]) }
|
|
66
|
+
def formats
|
|
67
|
+
@formats.dup.freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { params(value: T.untyped).void }
|
|
71
|
+
def formats=(value)
|
|
72
|
+
raise TypeError, "formats must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
73
|
+
|
|
74
|
+
formats = value.map(&:to_sym)
|
|
75
|
+
unknown = formats - FORMATS
|
|
76
|
+
raise ArgumentError, "unknown format(s): #{unknown.inspect}, expected a subset of #{FORMATS.inspect}" unless unknown.empty?
|
|
77
|
+
raise ArgumentError, 'formats must not be empty' if formats.empty?
|
|
78
|
+
|
|
79
|
+
@formats = formats.uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ->(preview, email, locale, &block) wrapping each rendering; it must call block.
|
|
83
|
+
sig { returns(T.nilable(Proc)) }
|
|
84
|
+
attr_reader :around_each
|
|
85
|
+
|
|
86
|
+
sig { params(value: T.untyped).void }
|
|
87
|
+
def around_each=(value)
|
|
88
|
+
raise TypeError, "around_each must be a Proc or nil, got #{value.class}" unless value.nil? || value.is_a?(Proc)
|
|
89
|
+
|
|
90
|
+
@around_each = value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Applied to written files only: the Guard always sees the unfiltered content.
|
|
94
|
+
sig { returns(T::Array[Proc]) }
|
|
95
|
+
attr_reader :output_filters
|
|
96
|
+
|
|
97
|
+
sig { params(value: T.untyped).void }
|
|
98
|
+
def output_filters=(value)
|
|
99
|
+
raise TypeError, "output_filters must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
100
|
+
|
|
101
|
+
@output_filters = value.map { |filter| output_filter(filter) }
|
|
102
|
+
end
|
|
103
|
+
|
|
48
104
|
sig { void }
|
|
49
105
|
def initialize
|
|
50
106
|
@guard = T.let(Guard.new, Guard)
|
|
51
107
|
@output_dir = T.let('tmp/activemail_previews', String)
|
|
52
108
|
@required_previews = T.let([], T::Array[String])
|
|
109
|
+
@locales = T.let(DEFAULT_LOCALES, T.any(T::Array[Symbol], Proc))
|
|
110
|
+
@formats = T.let(FORMATS, T::Array[Symbol])
|
|
111
|
+
@around_each = T.let(nil, T.nilable(Proc))
|
|
112
|
+
@output_filters = T.let([], T::Array[Proc])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
sig { params(value: T.untyped).returns(T::Array[Symbol]) }
|
|
118
|
+
def normalize_locales(value)
|
|
119
|
+
raise TypeError, "locales must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
120
|
+
raise ArgumentError, 'locales must not be empty' if value.empty?
|
|
121
|
+
|
|
122
|
+
value.map(&:to_sym)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
sig { params(filter: T.untyped).returns(Proc) }
|
|
126
|
+
def output_filter(filter)
|
|
127
|
+
return filter if filter.is_a?(Proc)
|
|
128
|
+
|
|
129
|
+
pattern, replacement = filter if filter.is_a?(Array) && filter.size == 2
|
|
130
|
+
raise TypeError, "output filter must be a Proc or [Regexp, String], got #{filter.inspect}" unless pattern.is_a?(Regexp) && replacement.is_a?(String)
|
|
131
|
+
|
|
132
|
+
->(content) { content.gsub(pattern, replacement) }
|
|
53
133
|
end
|
|
54
134
|
end
|
|
55
135
|
end
|