activemail 1.2.3 → 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 +55 -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 +17 -4
- 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
|
@@ -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
|
|
@@ -4,127 +4,85 @@
|
|
|
4
4
|
require 'nokogiri'
|
|
5
5
|
require 'sorbet-runtime'
|
|
6
6
|
|
|
7
|
-
require_relative '
|
|
7
|
+
require_relative 'builtin_rules'
|
|
8
|
+
require_relative 'preview_renderer'
|
|
9
|
+
require_relative 'rule'
|
|
8
10
|
|
|
9
11
|
module ActiveMail
|
|
10
12
|
module Quality
|
|
11
13
|
class Guard
|
|
12
14
|
extend T::Sig
|
|
13
15
|
|
|
14
|
-
# One failed invariant. :rule is a stable symbol for programmatic matching;
|
|
15
|
-
# :message is human-facing.
|
|
16
16
|
class Violation < T::Struct
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
17
19
|
const :rule, Symbol
|
|
18
20
|
const :message, String
|
|
21
|
+
|
|
22
|
+
sig { returns(String) }
|
|
23
|
+
def to_s
|
|
24
|
+
"[#{rule}] #{message}"
|
|
25
|
+
end
|
|
19
26
|
end
|
|
20
27
|
|
|
21
|
-
DISABLEABLE =
|
|
28
|
+
DISABLEABLE = BuiltinRules::NAMES
|
|
22
29
|
# Gmail clips messages past ~102KB.
|
|
23
30
|
DEFAULT_MAX_BYTES = 102_400
|
|
24
31
|
# A full HTML document smaller than this carries no real layout and is suspect.
|
|
25
32
|
DEFAULT_MIN_FULL_DOC_BYTES = 1_024
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@
|
|
32
|
-
@min_full_doc_bytes = T.let(positive_threshold!(:min_full_doc_bytes, min_full_doc_bytes), Integer)
|
|
33
|
-
unknown = disable - DISABLEABLE
|
|
34
|
-
raise ArgumentError, "unknown rule(s): #{unknown.inspect}, expected a subset of #{DISABLEABLE.inspect}" unless unknown.empty?
|
|
35
|
-
|
|
36
|
-
@disabled = T.let(disable.to_set, T::Set[Symbol])
|
|
34
|
+
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer, disable: T::Array[Symbol], rules: T::Array[Rule]).void }
|
|
35
|
+
def initialize(max_bytes: DEFAULT_MAX_BYTES, min_full_doc_bytes: DEFAULT_MIN_FULL_DOC_BYTES, disable: [], rules: [])
|
|
36
|
+
all = assemble_rules(BuiltinRules.new(max_bytes: max_bytes, min_full_doc_bytes: min_full_doc_bytes), disable, rules)
|
|
37
|
+
@html_rules = T.let(all.select { |rule| rule.level == :html }, T::Array[Rule])
|
|
38
|
+
@message_rules = T.let(all.select { |rule| rule.level == :message }, T::Array[Rule])
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
doc = Nokogiri::HTML(html)
|
|
45
|
-
check_well_formed(doc, violations)
|
|
46
|
-
check_table_roles(doc, violations)
|
|
47
|
-
check_img_alts(doc, violations)
|
|
48
|
-
check_full_document(html, doc, violations) if ::ActiveMail.full_document?(html)
|
|
49
|
-
violations
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
sig { params(html: String).returns(T::Boolean) }
|
|
53
|
-
def valid?(html)
|
|
54
|
-
violations(html).empty?
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
sig { params(rule: Symbol).returns(T::Boolean) }
|
|
60
|
-
def enabled?(rule)
|
|
61
|
-
!@disabled.include?(rule)
|
|
62
|
-
end
|
|
41
|
+
# A String only gets the HTML rules; a message gets both levels, except a declined send.
|
|
42
|
+
sig { params(html_or_message: T.untyped).returns(T::Array[Violation]) }
|
|
43
|
+
def violations(html_or_message)
|
|
44
|
+
return html_violations(html_or_message) if html_or_message.is_a?(String)
|
|
63
45
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
raise ArgumentError, "#{name} must be a positive integer, got #{value}" unless value.positive?
|
|
46
|
+
mail = PreviewRenderer.mail(html_or_message)
|
|
47
|
+
return [] if PreviewRenderer.declined?(mail)
|
|
67
48
|
|
|
68
|
-
|
|
49
|
+
html = PreviewRenderer.html_body(mail)
|
|
50
|
+
(html.empty? ? [] : html_violations(html)) + apply(@message_rules, [mail])
|
|
69
51
|
end
|
|
70
52
|
|
|
71
|
-
sig { params(
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
return if html.bytesize <= @max_bytes
|
|
75
|
-
|
|
76
|
-
violations << Violation.new(rule: :max_bytes, message: "HTML is #{html.bytesize} bytes, exceeds #{@max_bytes} (Gmail clipping)")
|
|
53
|
+
sig { params(html_or_message: T.untyped).returns(T::Boolean) }
|
|
54
|
+
def valid?(html_or_message)
|
|
55
|
+
violations(html_or_message).empty?
|
|
77
56
|
end
|
|
78
57
|
|
|
79
|
-
|
|
80
|
-
# silently bless malformed *output* (mismatched tags, bad entities) before send.
|
|
81
|
-
sig { params(doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
|
|
82
|
-
def check_well_formed(doc, violations)
|
|
83
|
-
return unless enabled?(:parse_error)
|
|
58
|
+
private
|
|
84
59
|
|
|
85
|
-
|
|
86
|
-
|
|
60
|
+
sig { params(builtin: BuiltinRules, disable: T::Array[Symbol], custom: T::Array[Rule]).returns(T::Array[Rule]) }
|
|
61
|
+
def assemble_rules(builtin, disable, custom)
|
|
62
|
+
unknown = disable - DISABLEABLE
|
|
63
|
+
raise ArgumentError, "unknown rule(s): #{unknown.inspect}, expected a subset of #{DISABLEABLE.inspect}" unless unknown.empty?
|
|
87
64
|
|
|
88
|
-
|
|
65
|
+
unique_names!(builtin.rules.reject { |rule| disable.include?(rule.name) } + custom)
|
|
89
66
|
end
|
|
90
67
|
|
|
91
|
-
sig { params(
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
offenders = doc.css('table').count { |table| table['role'] != 'presentation' }
|
|
96
|
-
return if offenders.zero?
|
|
68
|
+
sig { params(rules: T::Array[Rule]).returns(T::Array[Rule]) }
|
|
69
|
+
def unique_names!(rules)
|
|
70
|
+
duplicates = rules.map(&:name).tally.select { |_name, count| count > 1 }.keys
|
|
71
|
+
raise ArgumentError, "duplicate rule name(s): #{duplicates.inspect}" unless duplicates.empty?
|
|
97
72
|
|
|
98
|
-
|
|
73
|
+
rules
|
|
99
74
|
end
|
|
100
75
|
|
|
101
|
-
sig { params(
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
offenders = doc.css('img').count { |img| !img.key?('alt') }
|
|
106
|
-
return if offenders.zero?
|
|
107
|
-
|
|
108
|
-
violations << Violation.new(rule: :img_alt, message: "#{offenders} <img> missing an alt attribute")
|
|
76
|
+
sig { params(html: String).returns(T::Array[Violation]) }
|
|
77
|
+
def html_violations(html)
|
|
78
|
+
apply(@html_rules, [Nokogiri::HTML(html), html])
|
|
109
79
|
end
|
|
110
80
|
|
|
111
|
-
sig { params(
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
81
|
+
sig { params(rules: T::Array[Rule], args: T::Array[T.untyped]).returns(T::Array[Violation]) }
|
|
82
|
+
def apply(rules, args)
|
|
83
|
+
rules.flat_map do |rule|
|
|
84
|
+
rule.messages(args).map { |message| Violation.new(rule: rule.name, message: message) }
|
|
115
85
|
end
|
|
116
|
-
check_lang(doc, violations)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
sig { params(doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
|
|
120
|
-
def check_lang(doc, violations)
|
|
121
|
-
return unless enabled?(:lang)
|
|
122
|
-
|
|
123
|
-
html_tag = doc.at_css('html')
|
|
124
|
-
lang = html_tag && html_tag['lang']
|
|
125
|
-
return if lang && !lang.strip.empty?
|
|
126
|
-
|
|
127
|
-
violations << Violation.new(rule: :lang, message: '<html> must declare a non-blank lang attribute')
|
|
128
86
|
end
|
|
129
87
|
end
|
|
130
88
|
end
|
|
@@ -8,36 +8,34 @@ module ActiveMail
|
|
|
8
8
|
module Minitest
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
11
|
-
sig { params(
|
|
12
|
-
def assert_email_quality(
|
|
13
|
-
|
|
14
|
-
# assert is injected by the host's Minitest test class.
|
|
15
|
-
T.unsafe(self).assert violations.empty?,
|
|
16
|
-
"email quality violations:\n#{violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")}"
|
|
11
|
+
sig { params(html_or_message: T.untyped, guard: Guard).void }
|
|
12
|
+
def assert_email_quality(html_or_message, guard: ActiveMail::Quality.guard)
|
|
13
|
+
assert_no_quality_violations(guard.violations(html_or_message))
|
|
17
14
|
end
|
|
18
15
|
|
|
19
|
-
sig
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
sig do
|
|
17
|
+
params(preview: T.untyped, email: String, guard: Guard, locale: Symbol, around_each: T.nilable(Proc)).void
|
|
18
|
+
end
|
|
19
|
+
def assert_preview_quality(preview, email, guard: ActiveMail::Quality.guard, locale: I18n.default_locale,
|
|
20
|
+
around_each: ActiveMail::Quality.config.around_each)
|
|
21
|
+
assert_no_quality_violations(preview_violations(preview, email, locale, guard, around_each))
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
module ClassMethods
|
|
25
25
|
extend T::Sig
|
|
26
26
|
|
|
27
|
-
# One test per discovered preview; a non-required preview that can't render
|
|
28
|
-
# is skipped, a required one fails (see #render_preview_or_skip).
|
|
29
27
|
sig { params(guard: Guard).void }
|
|
30
28
|
def assert_quality_for_all_previews(guard: ActiveMail::Quality.guard)
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
config = ActiveMail::Quality.config
|
|
30
|
+
cases = PreviewRenderer.cases(config)
|
|
33
31
|
# A silent no-test run would look like everything passed.
|
|
34
|
-
Kernel.warn('[activemail] assert_quality_for_all_previews: no previews discovered.') if
|
|
35
|
-
|
|
36
|
-
key = PreviewRenderer.key(preview, email)
|
|
32
|
+
Kernel.warn('[activemail] assert_quality_for_all_previews: no previews discovered.') if cases.empty?
|
|
33
|
+
cases.each_with_index do |(preview, email, locale), i|
|
|
37
34
|
# Index prefix: distinct keys can normalize to the same method name.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
T.unsafe(self).
|
|
35
|
+
name = "test_#{i}_#{PreviewRenderer.key(preview, email).gsub(/\W/, '_')}_#{locale}_email_quality"
|
|
36
|
+
T.unsafe(self).define_method(name) do
|
|
37
|
+
violations = T.unsafe(self).send(:render_preview_or_skip, preview, email, locale, guard, config.required_previews)
|
|
38
|
+
T.unsafe(self).send(:assert_no_quality_violations, violations)
|
|
41
39
|
end
|
|
42
40
|
end
|
|
43
41
|
end
|
|
@@ -50,13 +48,31 @@ module ActiveMail
|
|
|
50
48
|
|
|
51
49
|
private
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
sig { params(violations: T::Array[Guard::Violation]).void }
|
|
52
|
+
def assert_no_quality_violations(violations)
|
|
53
|
+
# assert is injected by the host's Minitest test class.
|
|
54
|
+
T.unsafe(self).assert violations.empty?,
|
|
55
|
+
"email quality violations:\n#{violations.map { |v| " - #{v}" }.join("\n")}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig do
|
|
59
|
+
params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, around_each: T.nilable(Proc))
|
|
60
|
+
.returns(T::Array[Guard::Violation])
|
|
61
|
+
end
|
|
62
|
+
def preview_violations(preview, email, locale, guard, around_each)
|
|
63
|
+
PreviewRenderer.within(preview, email, locale, around_each) { |mail| guard.violations(mail) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A non-required preview that can't render is skipped, not a silent pass.
|
|
67
|
+
sig do
|
|
68
|
+
params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, required: T::Array[String])
|
|
69
|
+
.returns(T::Array[Guard::Violation])
|
|
70
|
+
end
|
|
71
|
+
def render_preview_or_skip(preview, email, locale, guard, required)
|
|
72
|
+
preview_violations(preview, email, locale, guard, ActiveMail::Quality.config.around_each)
|
|
58
73
|
rescue StandardError => e
|
|
59
|
-
|
|
74
|
+
key = PreviewRenderer.key(preview, email)
|
|
75
|
+
msg = "#{key} [#{locale}] did not render (#{e.class}: #{e.message})"
|
|
60
76
|
required.include?(key) ? T.unsafe(self).flunk("required preview #{msg}") : T.unsafe(self).skip(msg)
|
|
61
77
|
end
|
|
62
78
|
end
|